Gunicorn, Uvicorn, and choosing a worker class
coreadvanced**Gunicorn** is a process manager: one master forks N workers, watches them, and restarts them when they die. A **worker class** decides how each worker handles concurrency — `sync` is one request at a time, `gthread` uses a thread pool, and an ASGI worker runs an event loop. **Uvicorn** is an ASGI server; Django documents running it directly, or under Gunicorn with `-k uvicorn_worker.UvicornWorker` so you get Uvicorn's event loop and Gunicorn's supervision.
Think of it as
Separate the two jobs and the choices stop being confusing. Supervision is one job: something has to start N processes, notice when one dies, replace it, and hand you a single thing to signal for a graceful restart. Gunicorn is very good at that, and it is why the common production shape is Gunicorn on the outside even when the code is async. Concurrency is the other job, and it belongs to the worker class. `sync`, the documented default, handles exactly one request per worker at a time; concurrency is then entirely a function of how many processes you run, and a worker waiting on a slow database query or a slow HTTP call is a worker doing nothing. That is fine — good, even — behind a buffering reverse proxy, because the proxy absorbs slow clients and the model is trivially predictable. `gthread` gives each worker a small thread pool, so one process can have several requests in flight while most of them are blocked on I/O; under the GIL those threads do not run Python in parallel, so this buys concurrency on I/O rather than CPU throughput. Gunicorn makes that substitution explicit: set `threads` above one on the `sync` worker and "the gthread worker type will be used instead". An ASGI worker is a different model again — one event loop per process, thousands of connections held cheaply, which is the only shape that makes WebSockets, SSE and long-lived streaming affordable. The decision, then, is not "which is fastest" but "what does this deployment need to hold". Ordinary request/response Django behind nginx: `sync` workers, and add processes for capacity. The same app where a handful of views make several outbound calls each: `gthread`, so a worker is not idle for the duration of every call. Anything holding connections open — a chat feature, a progress stream, async views you actually want to run concurrently — needs ASGI, which for Django means `asgi.py` and Uvicorn. Two practical notes save time here. Async only helps if the code path is async all the way down: an `async def` view that calls the ORM synchronously blocks its event loop, and blocking an event loop is worse than blocking a thread, because it stalls every other connection that loop is serving. And running under Gunicorn does not change the code you deploy — it changes which callable you point at, `config.wsgi:application` or `config.asgi:application`, so the mistake of pairing an ASGI worker class with the WSGI callable is easy to make and produces a confusing failure rather than an obvious one.
What we're doing: Run the same Django project two ways — sync workers for the request/response site, ASGI workers for the streaming endpoints — and see what changes.
- 2–4
- With `sync`, concurrency is worker count and nothing else. That makes capacity planning arithmetic instead of guesswork — which is the main reason it remains the right default behind a buffering proxy.
- 7–11
- The WSGI callable, a Unix socket rather than a port, and a request timeout below nginx's. `--max-requests` with jitter recycles workers to bound memory growth.
- 16–20
- The ASGI form Django documents, with the worker class from the separate `uvicorn-worker` package. Note the different callable: `config.asgi`, not `config.wsgi`.
- 20–20
- A longer graceful timeout for the streaming tier, because a connection that is *meant* to stay open needs more time to drain than a request/response worker does.
- 26–26
- `gthread` is the middle option: no async code, no ASGI, but a worker can have several I/O-bound requests in flight. The GIL means the threads share one core's worth of Python execution.
- 28–32
- Gunicorn documents that raising `threads` on `sync` switches the worker type for you. Writing `-k gthread` explicitly costs nothing and makes the deployment say what it is doing.
Why this works: One image serves both shapes: predictable sync capacity for the site, an event loop for the endpoints that hold connections, and a documented middle option when the constraint is outbound I/O rather than streaming.
Pointing an ASGI worker class at the WSGI callable
Wrong
Better
What you see: Workers that fail during boot, or serve nothing but errors, with a traceback about the application being called with the wrong number of arguments — and no mention of WSGI or ASGI anywhere in it.
Why: WSGI and ASGI are different calling conventions: a WSGI application is a callable taking `(environ, start_response)`, while an ASGI application is an async callable taking `(scope, receive, send)`. The worker class decides which convention the server uses, and the module path decides which callable it gets, so the two have to agree. `startproject` generates both `wsgi.py` and `asgi.py`, which makes the mismatch easy to reach by editing only one of the two places. The rule is to change them together: `-k uvicorn_worker.UvicornWorker` always goes with `config.asgi:application`.
- WSGI · sync workers
- One request per worker, from first byte to last
- Concurrency = worker count. 5 workers = 5 requests at once
- A 2-second outbound call occupies a whole worker for 2 seconds
- Memory scales with workers: every process is a full copy
- Cannot hold a WebSocket or an SSE stream affordably
- Predictable, boring, and correct for most Django sites
- ASGI · Uvicorn workers
- One event loop per process, many requests in flight
- A request awaiting I/O yields the loop instead of holding it
- Long-lived connections (WebSocket, SSE) cost little while idle
- Only pays off if the path is async all the way down
- A synchronous ORM call inside `async def` blocks every connection
- Still wants Gunicorn outside it, for supervision and restarts
The worker classes, and what each is for
Together
The commands Django itself documents
Together
Remember: Gunicorn supervises processes; the worker class decides concurrency, and those are separate decisions. `sync` is the default and means one request per worker, which makes capacity equal worker count — the right, boring choice behind a buffering proxy. `gthread` adds a thread pool for I/O-bound views, and gunicorn switches you to it automatically if you set `--threads` above one on `sync`. ASGI workers hold many connections on one event loop, which is the only affordable way to serve WebSockets or SSE; Django documents `python -m gunicorn myproject.asgi:application -k uvicorn_worker.UvicornWorker`. Match the callable to the class, and remember an event loop is only fast while nothing blocks it.
See also: how many workers and threads · timeouts max requests and graceful restart · wsgi and asgi as interfaces · the sync async bridge

