gunicorn
Gunicorn is a pre-fork HTTP server for Python web apps on UNIX. You point it at an importable callable, as in `gunicorn myproject.wsgi:application`, and a master process it calls the arbiter forks N worker processes. All the workers accept connections from one shared listening socket, so the kernel does the load balancing and the arbiter never touches a request itself. What the arbiter does is supervise: it watches each worker's heartbeat, kills and replaces one that stops responding, reloads on SIGHUP, and can hand its listening socket to a brand new master for a zero-downtime upgrade. That is the whole idea, and it is why almost every Django and Flask deployment guide starts here: you get a process manager, graceful restarts, and worker recycling with one command and no config file. The 25 and 26 releases widened it past WSGI, adding a built-in `asgi` worker class for FastAPI and Starlette, beta HTTP/2, and a `gunicornc` control socket for inspecting a running instance.
For a synchronous WSGI app behind a proxy, Gunicorn is still the default answer and deserves to be: the arbiter model is simple, the flags have barely changed in a decade, and every hosting guide already assumes it. If your app is async, reach for uvicorn first and only add Gunicorn when you specifically want its supervision.
Use it if
- You are deploying a synchronous WSGI app (Django, Flask, Pyramid) behind nginx and want worker supervision, graceful reload, and process recycling without adding a second piece of software
- You need to paper over a memory leak you cannot fix today: --max-requests with --max-requests-jitter retires each worker after a set number of requests and is the standard mitigation for a slowly growing RSS
- Your request handling is blocking or CPU-bound and you want real OS processes rather than an event loop, so a C extension that holds the GIL for a second cannot stall every other in-flight request
- You want one deployment recipe for both protocols: 26.0 ships sync, gthread, gevent, tornado, and asgi worker classes chosen by a single --worker-class flag, so a WSGI service and an ASGI service run under identical systemd units
- You need an operations surface on the box itself: 25.1 added the gunicornc control socket with show workers, show stats, worker add/remove, and reload, which beats guessing from ps output
- You run on Windows. Gunicorn forks and drives everything with UNIX signals, there is no Windows build, and there will not be one. Waitress or uvicorn are the answers there, and WSL is a workaround rather than a deployment target
- Everything you ship is async. The built-in asgi worker is real and 26.0.0 reports 438 of 444 passing on its cross-framework compatibility grid, but uvicorn is the server ASGI frameworks actually test against, and a plain uvicorn process supervised by systemd or Kubernetes is one moving part instead of two. Gunicorn's value is the arbiter, and your orchestrator may already be that
- You want to face the internet directly. The docs are explicit that Gunicorn belongs behind a buffering proxy: a sync worker is occupied for the entire lifetime of a request, so a handful of deliberately slow clients can consume your whole worker pool. nginx or a cloud load balancer absorbing the slow reads is not optional
- You depend on the eventlet worker. 26.0.0 deleted the eventlet worker class outright with no deprecation window, so an unpinned upgrade fails at startup with an invalid worker class, and moving to gevent means re-auditing every library that assumed eventlet's monkey patching
- Memory per unit of concurrency matters. Pre-fork means each worker is a full copy of the interpreter and your application, so a heavy Django process multiplies by the worker count. --preload-app shares pages at fork time, but CPython's reference counting writes to object headers, so copy-on-write gives back less than you expect
- You want a large maintainer team behind your production HTTP server. The README opens by asking for sponsorship to keep volunteer maintenance going, and the newest features (HTTP/2, dirty arbiters) both ship labeled beta
Setup reality
pip install gunicorn needs Python 3.10 or later and pulls exactly one dependency, packaging. Everything interesting is an extra: gevent, tornado, http2 (h2 >= 4.1.0), fast (the gunicorn_h1c C parser), and setproctitle, which is the difference between readable process names in ps and a row of identical python entries. Ask for a worker class whose extra you did not install and you get a startup error about an invalid class URI, not a helpful hint. Three defaults bite people on day one. The bind default is 127.0.0.1:8000, so a container that looks healthy answers nothing from outside until you set 0.0.0.0. There is no access log at all unless you pass --access-logfile -. And forwarded_allow_ips defaults to 127.0.0.1, so when your proxy runs on another host or another container, every X-Forwarded-Proto header is ignored, your app decides the request was plain HTTP, and you get an infinite redirect loop the first time someone enables SSL redirects. A gunicorn.conf.py in the working directory is auto-loaded; one anywhere else needs -c and is silently skipped if you forget. Under systemd, do not pass --daemon, do send logs to stdout, and use KillMode=mixed so the arbiter gets the TERM and drains its own workers. Finally, the app argument is an import path rather than a file path, so it resolves against sys.path and fails with ModuleNotFoundError if you launch from the wrong directory.
Patterns
Serve a Django or Flask apprun-a-wsgi-app
# Django: myproject/wsgi.py defines `application`
gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 4
# Flask: app.py defines `app`
gunicorn app:app -b 0.0.0.0:8000 -w 4
# app factory
gunicorn 'app:create_app()' -b 0.0.0.0:8000The part before the colon is a Python import path, not a filename, so it resolves through sys.path and fails with ModuleNotFoundError if you start from the wrong directory; --pythonpath or --chdir fixes that. The default bind is 127.0.0.1:8000, which inside a container means the health check from outside never connects.
Pick worker and thread countssize-the-worker-pool
# sync workers: N workers == N concurrent requests
gunicorn app:app --workers $((2 * $(nproc) + 1))
# threaded: good when requests mostly wait on a database
gunicorn app:app --workers 4 --worker-class gthread --threads 4
# greenlets: many slow upstream calls, monkey-patched IO
pip install 'gunicorn[gevent]'
gunicorn app:app --workers 4 --worker-class gevent --worker-connections 1000(2 x cores) + 1 is the documented starting point, not a tuned answer; measure before trusting it. The sync worker handles exactly one request at a time, so an endpoint that waits three seconds on an upstream API pins a whole process. gevent only helps if every blocking library in the process is patchable, and psycopg2 or a C driver that blocks in C will stall the entire event loop.
Move settings into gunicorn.conf.pyuse-a-config-file
# gunicorn.conf.py (auto-loaded from the working directory)
bind = "unix:/run/gunicorn.sock"
workers = 4
worker_class = "gthread"
threads = 4
timeout = 30
graceful_timeout = 30
keepalive = 5
max_requests = 1000
max_requests_jitter = 100
accesslog = "-"
errorlog = "-"
loglevel = "info"
forwarded_allow_ips = "127.0.0.1"
preload_app = FalseEvery CLI flag has a config-file twin with dashes turned into underscores. Only a file literally named gunicorn.conf.py in the working directory is picked up automatically; anywhere else needs -c and is otherwise ignored without a warning. The file is executed as Python inside the arbiter before any fork, so anything you open there is inherited by every worker.
Serve FastAPI or Starlette with the built-in ASGI workerrun-an-asgi-app
gunicorn main:app --worker-class asgi --workers 4 --bind 0.0.0.0:8000
# the older recipe, a uvicorn event loop inside the gunicorn arbiter
gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4The asgi worker class is built in since the 25 line, so no extra package is needed. Lifespan events and websockets are handled by it, but check your framework against the compatibility grid in the 26.0.0 release notes before switching a production service, since that grid is where the remaining failures live.
Bind a UNIX socket and trust the proxy's headerssit-behind-nginx
gunicorn app:app \
--bind unix:/run/gunicorn.sock \
--forwarded-allow-ips="10.0.0.0/8" \
--proxy-allow-from="10.0.0.0/8"
# nginx
# proxy_set_header Host $http_host;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_pass http://unix:/run/gunicorn.sock;forwarded_allow_ips defaults to 127.0.0.1, so a proxy in a different container or on a different host is not trusted and X-Forwarded-Proto is dropped. The symptom is an app that thinks every request arrived over plain HTTP, which turns SECURE_SSL_REDIRECT into a redirect loop and breaks OAuth callback URLs. Set it to the proxy's actual range rather than "*" unless the socket is unreachable from anywhere untrusted.
Signals for reload, drain, and zero-downtime upgradereload-and-restart
PID=$(cat /run/gunicorn.pid)
kill -HUP $PID # reload config, gracefully replace all workers
kill -TERM $PID # graceful shutdown
kill -QUIT $PID # quick shutdown
kill -USR1 $PID # reopen log files after logrotate
# zero-downtime binary upgrade
kill -USR2 $PID # new arbiter inherits the listening socket
kill -WINCH $PID # drain the old arbiter's workers
kill -QUIT $PID # retire the old arbiter
gunicorn app:app --reload # development onlyUSR2 only works if the original command line and working directory are still valid for the new code, which rules it out for deployments that swap a symlinked release directory unless you set --chdir carefully. --reload adds a file watcher and restarts workers on every save; the docs call it a development feature and mean it.
Understand what --timeout actually killstune-timeouts
gunicorn app:app --timeout 30 --graceful-timeout 30 --keep-alive 5--timeout is not an HTTP request deadline, it is the arbiter's heartbeat window. A worker that has not checked in for that many seconds is assumed hung, gets SIGKILL, and the client sees a dropped connection with [CRITICAL] WORKER TIMEOUT in the log. A genuine 60-second report endpoint therefore fails at 30 seconds no matter what your proxy allows. Move long work to a queue; raising --timeout just widens the window in which a truly wedged worker keeps serving nothing.
Get access and error logs into stdoutconfigure-logging
gunicorn app:app \
--access-logfile - \
--error-logfile - \
--log-level info \
--capture-output \
--access-logformat '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s %(D)s "%(f)s" "%(a)s"'There is no access log by default, which is why a fresh deployment looks silent. "-" means stdout, the right choice under systemd or any container runtime. %(D)s is request time in microseconds, the field you will want first. --capture-output redirects your application's own stdout and stderr into the error log; without it, a bare print() or an unexpected traceback written to stderr from a worker can vanish.
Share memory with --preload without corrupting connectionspreload-and-post-fork
# gunicorn.conf.py
preload_app = True
def post_fork(server, worker):
from django.db import connections
for conn in connections.all():
conn.close()
def worker_int(worker):
worker.log.info("worker %s interrupted", worker.pid)preload_app imports the app once in the arbiter before forking, cutting startup time and initial memory. The trap is any file descriptor created at import time: database connections, SQLite handles, gRPC channels, and metrics files are inherited by every worker and then interleave on the same socket, producing errors that look like database corruption. Close and rebuild them in post_fork, or leave preload off. Note also that --reload and preload do not combine.
Restart workers on a request budgetrecycle-workers
gunicorn app:app --max-requests 1000 --max-requests-jitter 200Without jitter every worker reaches the limit at roughly the same moment and they all restart together, which shows up as a periodic latency cliff. This is a mitigation for a leak, not a cure, and it also hides the leak from your memory graphs, so leave a note in the config saying why it is there.
A unit file that reloads and drains correctlyrun-under-systemd
[Unit]
Description=gunicorn for myproject
After=network.target
[Service]
User=app
Group=www-data
WorkingDirectory=/srv/app
ExecStart=/srv/app/.venv/bin/gunicorn myproject.wsgi:application -c /srv/app/gunicorn.conf.py
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targetCall the venv's gunicorn by absolute path so the shebang selects the right interpreter; activating the venv in a shell wrapper adds a process systemd then tracks instead of the arbiter. KillMode=mixed sends SIGTERM to the arbiter only and lets it drain its own workers rather than having systemd shoot all of them. Never combine this with --daemon.
Turn on the beta HTTP/2 support or the C parserenable-http2-or-fast-parser
pip install "gunicorn[http2]"
gunicorn main:app --worker-class asgi \
--http-protocols "h2,http/1.1" \
--certfile cert.pem --keyfile key.pem
pip install "gunicorn[fast]"
gunicorn app:app --http-parser fast # or 'auto'HTTP/2 is beta in the 25 and 26 line and needs the http2 extra for h2 >= 4.1.0. http_protocols takes a comma-separated string, not a Python list; passing a list was a documented mistake fixed in the 25.3.0 docs. The fast parser lives in a separate C package, gunicorn_h1c, pulled in by the fast extra, and in auto mode Gunicorn quietly falls back to the pure Python parser when the installed version is older than the release requires.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| uvicorn | PyPI | Your stack is entirely ASGI (FastAPI, Starlette, Litestar) and your orchestrator already restarts crashed processes, so you do not need a second supervisor. |
| waitress | PyPI | You need a pure-Python WSGI server that runs on Windows, or you want zero C dependencies in the image. |
| hypercorn | PyPI | You want production HTTP/2 and HTTP/3 with an ASGI app and do not want to rely on a beta implementation. |
| uwsgi | PyPI | You need the extra operational features (native cron, cheaper subscription scaling, emperor mode) and accept a much larger configuration surface. |