Application Servers
The application server is the process that runs your Python code. The web server in front of it (see Web servers) terminates TLS, serves static files and buffers slow clients. This page is about the process behind it.
It is also the layer where the defaults are least like what you want in production. Out of the box
gunicorn serves one request at a time, and --timeout means two different things depending on the
worker class.
Everything below was run against gunicorn 26.2.0, uvicorn 0.54.0 and uvicorn-worker 0.4.0 on
Python 3.15. Wall times come from curl -w '%{time_total}'.
Cookbook
Run a WSGI app
gunicorn -b 127.0.0.1:8000 -w 5 myproject.wsgi:application
-b is the socket to listen on, -w the number of worker processes, and the last argument is
module:callable. Django generates that callable in myproject/wsgi.py, a single file Flask app is
usually app:app.
Size the worker pool
The gunicorn docs give (2 x $num_cores) + 1 as a starting point, and add that 4 to 12 workers
are typically enough for heavy traffic. Do not derive the number from how many clients you
expect: past a point more workers means more context switching and less throughput.
On a 2 core box that is 5.
Choose the worker class
gunicorn -k sync -w 5 app:app # default: one request per process, at a time
gunicorn -k gthread -w 5 --threads 4 app:app # thread pool per process, for blocking I/O
gunicorn -k uvicorn_worker.UvicornWorker -w 5 app:app # ASGI
sync assumes your handlers are CPU bound and short. If they spend their time waiting on a
database, an HTTP call or a queue, gthread gets far more out of the same process count, without
touching the application code.
Run an ASGI app
Uvicorn alone is enough:
uvicorn --host 127.0.0.1 --port 8000 myapp:app
For several processes with a supervisor that restarts them, put gunicorn in front as the process manager and let uvicorn handle the protocol:
pip install uvicorn-worker
gunicorn -b 127.0.0.1:8000 -w 5 -k uvicorn_worker.UvicornWorker myapp:app
Reload workers without closing the socket
kill -HUP <master-pid>
The master keeps the listening socket, starts new workers and retires the old ones, so nothing in
flight is dropped and no connection is refused. Measured: worker pids went from 643 and 644 to 655
and 657 while the master pid never changed. In a container gunicorn is usually PID 1 and
docker kill -s HUP <container> does the same, but an image is not edited in place: new code
means a new image, so you replace the container instead.
Trust the proxy in front of you
Both servers take the scheme from X-Forwarded-Proto, but only when the connection comes from an
address they trust. Uvicorn also takes the client IP from X-Forwarded-For; gunicorn does not, and
REMOTE_ADDR stays the proxy’s address. The default is loopback only, 127.0.0.1,::1, for both.
If the proxy is another container or another host, name it:
gunicorn --forwarded-allow-ips 10.0.0.0/8 ...
uvicorn --forwarded-allow-ips 10.0.0.0/8 ...
Bind where the traffic can reach you
In a container, 127.0.0.1 is reachable only from inside that container. Listen on all interfaces
and let the network layer do the restricting:
uvicorn --host 0.0.0.0 --port 8000 myapp:app
Gotchas
The default is a single worker, so one slow request blocks the site
gunicorn app:app with no flags boots exactly one sync worker. Six requests all came back with the
same pid. Then, with one request sleeping in a handler, an unrelated request to / waited
29.2 s, about the 30 s default timeout: it was not served by a busy worker, it was served by
the replacement worker once the first one had been killed for timing out.
Always pass -w. One worker is a development default that looks fine until two people use the site
at once.
—timeout is a watchdog, not a request deadline, and it does not mean the same thing twice
The same flag, the same handler sleeping 12 seconds:
gunicorn -t 5 -k sync -> 500 after 5.24 s, "WORKER TIMEOUT" in the log
gunicorn -t 5 -k gthread --threads 4 -> 200 after 12.00 s, nothing in the log
With sync workers the master kills a worker that has not checked in, and the request dies with it,
as a 500. With gthread the worker keeps answering the master from another thread, so the request
runs to completion and the timeout never fires. Neither is a per request limit: for that you need
the proxy in front, or a timeout inside the handler.
—preload plus SIGHUP serves stale code, silently
--preload imports the application once in the master and forks the workers from it. That saves
memory and catches an import error before any worker starts. It also means a reload forks from the
module already in memory:
no --preload : v1, edit the source, HUP -> v2
--preload : v1, edit the source, HUP -> v1
Same signal, same edit, and the second one keeps serving the old code with nothing in the log to say
so. If your deploy is “write the files, send HUP”, --preload breaks it. Anything opened at import
time is also inherited by every worker, so a connection pool or a socket created at module level is
shared across processes after the fork.
Forwarded headers are a trust setting, in both directions
The default trusts loopback, which cuts both ways.
Behind a proxy that is not on loopback, the headers are ignored: the same request arrived as
scheme=http with the proxy’s address as the client, while from loopback it arrived as
scheme=https, and under uvicorn with the spoofed 203.0.113.7 as the client. An app that builds
absolute URLs then emits http:// links and redirect loops, and anything keyed on the client IP
keys on the proxy.
The other direction is why --forwarded-allow-ips '*' is not the fix: it lets whoever can reach the
port set their own IP and scheme. Name the proxy’s address or network.
uvicorn.workers is deprecated, the worker moved to its own package
gunicorn -k uvicorn.workers.UvicornWorker still runs. It raises a DeprecationWarning, which
Python hides by default, so the gunicorn log stays silent; with PYTHONWARNINGS=default it reads:
DeprecationWarning: The `uvicorn.workers` module is deprecated.
Please use `uvicorn-worker` package instead.
Install uvicorn-worker and use uvicorn_worker.UvicornWorker.
uWSGI: SIGTERM reloads, and the project is in maintenance mode
Two things to know before reaching for uWSGI on a new project.
Before 2.1 it treats SIGTERM as reload, not shutdown, so the usual way to stop a process brings
it back. Use SIGINT or SIGQUIT. That is straight from the project’s own best practices page,
linked below.
And the README states: “The project is in maintenance mode (only bugfixes and updates for new languages apis)”. Existing deployments are fine. For something new, gunicorn or uvicorn is the shorter path.
Links and references
- Gunicorn design, worker classes and the worker count formula.
- Gunicorn settings, every flag with its default.
- Uvicorn deployment, and uvicorn-worker.
- Things to know (best practices) - uWSGI documentation, worth reading in full if you maintain a uWSGI deployment.