Filter concepts by levelShowing all levels.

Django · Deployment Workers

Gunicorn, Uvicorn and Deployment Workers

Which worker, how many, and how each one ends

  • Supervision and concurrency are separategunicorn manages processes; the worker class decides how each serves
  • Four ceilings, take the lowestCPU, memory, database connections, workload — the formula answers only the first
  • `--timeout` watches silencedefault 30, and it belongs below the proxy read timeout
  • max-requests needs jitterwithout it every worker recycles at the same instant
  • Grace periods nest outwardgunicorn < systemd < the container, or the platform sends the kill

1.Gunicorn, Uvicorn, and choosing a worker class

**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.

Key features

  • Gunicorn supervises; the worker class decides concurrency — two separate jobs
  • `worker_class` defaults to `sync`: one request per worker at a time
  • Setting `threads` > 1 on `sync` silently switches you to the `gthread` worker
  • ASGI workers hold many idle connections on one loop — the only affordable way to stream
  • Django documents `-k uvicorn_worker.UvicornWorker` from the `uvicorn-worker` package
  • An `async def` view that calls the ORM synchronously blocks the whole event loop

The worker classes, and what each is for

The worker classes, and what each is for
ClassConcurrency modelChoose it when
`sync` (default)one request per worker, start to finishordinary Django behind a buffering proxy
`gthread`a thread pool inside each workerviews that wait on I/O; GIL means no CPU gain
ASGI worker (Uvicorn)one event loop per processWebSockets, SSE, streaming, genuinely async views
`gevent`greenlets; may need library patchesvery high I/O concurrency, when you accept the patching

The commands Django itself documents

The commands Django itself documents
GoalCommand
WSGI, the simplest form`gunicorn myproject.wsgi`
ASGI, single process`python -m uvicorn myproject.asgi:application`
ASGI with reload, for development`python -m uvicorn myproject.asgi:application --reload`
ASGI with supervision`python -m gunicorn myproject.asgi:application -k uvicorn_worker.UvicornWorker`
install for that last one`python -m pip install uvicorn uvicorn-worker gunicorn`
Sync workers and ASGI workers, on the same slow view

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
  • 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

Command

bash
gunicorn config.wsgi:application -k gthread --workers 4 --threads 4

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.

2.How many workers, how many threads

Gunicorn starts with **one** worker and suggests "a positive integer generally in the `2-4 x $(NUM_CORES)` range"; its design guide gives `workers = (2 × CPU cores) + 1` as a starting point and warns that "workers ≠ clients" — typically only **4–12** workers handle heavy traffic. But the roadmap's own note is the real rule: worker count is constrained by CPU, memory, database connections *and* workload, and the smallest of those ceilings is your answer.

Key features

  • Gunicorn: `workers` "Default: 1", suggested "2-4 x $(NUM_CORES)"; the design guide starts at (2 × cores) + 1
  • "Workers ≠ clients. Gunicorn typically needs only 4–12 workers to handle heavy traffic"
  • Take the LOWEST of four ceilings: CPU, memory, database connections, workload
  • `threads` "Default: 1" and "only affects the Gthread worker type"
  • Concurrency is workers × threads — and so is your database connection demand
  • More instances add CPU and RAM; more workers only redivide one box

The four ceilings, worked on one 4-core, 4 GB host

The four ceilings, worked on one 4-core, 4 GB host
CeilingHow you compute itThis host
CPU`(2 × cores) + 1`(2 × 4) + 1 = **9**
Memory(RAM − headroom) ÷ measured RSS per worker3.2 GB ÷ 220 MB ≈ **14**
DB connections(`max_connections` − reserved − other tiers) ÷ hosts(100 − 20 − 24) ÷ 3 hosts ≈ **18**
Workloadgunicorn: "typically only 4–12 workers"**4–12**
**Answer**the lowest of them**9** — CPU binds first here

Adding a worker, adding a thread, adding a host

Adding a worker, adding a thread, adding a host
You addCPU capacityMemory costDB connections
a workermore parallel Pythona full copy of the process+1 per worker (with `CONN_MAX_AGE`)
a thread (gthread)**none** — the GIL serialises Pythonsmall; shared memory+1 per concurrent request
a hostgenuinely more cores and RAMa whole machine+ workers × 1, fleet-wide
Four ceilings — and the one that binds

The formula answers the CPU question only. The binding ceiling on a given host may be memory or, most often at scale, the shared database connection budget — which no single machine can see.

  • Four horizontal bars on a shared scale from zero to sixty workers, each showing the maximum worker count allowed by one constraint on a four-core, four-gigabyte host.
  • CPU, computed as two times four cores plus one, allows nine workers — the shortest bar.
  • Memory, 3.2 gigabytes divided by a measured 220 megabytes of resident memory per worker, allows about fourteen.
  • Gunicorn guidance of four to twelve workers for heavy traffic is drawn as a band ending at twelve.
  • Database connections, computed as one hundred max_connections minus twenty reserved minus twenty-four used by Celery, divided across three hosts, allows about eighteen.
  • A vertical marker at nine shows the answer: the lowest ceiling wins, and here it is CPU.
  • A footnote notes that adding a second host lowers the database ceiling for every host, because that budget is shared.

Command

bash
gunicorn config.wsgi:application --workers 9        # min(CPU, RAM, DB, workload)

The formula `(2 × cores) + 1` answers the CPU question and nothing else. Take the lowest of four ceilings — CPU, memory divided by *measured* RSS, the shared database connection budget, and gunicorn's own "typically 4–12 workers" guidance — because that is what the roadmap means by "not an arbitrary more-is-better rule". Remember the connection ceiling is fleet-wide: workers × hosts, plus Celery, against `max_connections`. Threads default to 1, only affect `gthread`, buy I/O concurrency and never CPU throughput, and multiply connection demand. And real capacity comes from more instances behind the load balancer, since more workers only redivide one box.

3.Timeouts, max requests, and restarting without dropping anything

Gunicorn's `--timeout` (default **30**) means "workers silent for more than this many seconds are killed and restarted" — it is a watchdog, not a request deadline. `--max-requests` (default **0**, disabled) recycles a worker after N requests, which gunicorn describes as "a simple method to help limit the damage of memory leaks", and `--max-requests-jitter` staggers those restarts. `--graceful-timeout` (default **30**) is how long a worker gets to finish in-flight requests after a restart signal before it is "force killed".

Key features

  • `--timeout` (default 30) kills workers *silent* for that long — a watchdog, not a deadline
  • Keep `--timeout` below the proxy read timeout so the app fails first, with a traceback
  • `--max-requests` (default 0 = off) bounds leak damage by recycling workers
  • Always pair it with `--max-requests-jitter`, or every worker restarts at once
  • `--graceful-timeout` (default 30) is the drain window before stragglers are force killed
  • systemd `TimeoutStopSec` and the container grace period must both exceed it

The four settings, with gunicorn's documented defaults

The four settings, with gunicorn's documented defaults
SettingDefaultWhat it actually controls
`--timeout``30`seconds of **silence** before a worker is killed and restarted
`--graceful-timeout``30`time to finish in-flight requests after a restart signal
`--max-requests``0` (off)requests a worker serves before restarting itself
`--max-requests-jitter``0`random subtraction per worker: `randint(0, jitter)`
`--keepalive``2`idle wait on a keep-alive connection — **ignored by `sync`**

Which signal to send, and what gunicorn does with it

Which signal to send, and what gunicorn does with it
SignalEffectUse it to
`TERM`graceful shutdown, up to `graceful_timeout`stop the server without dropping requests
`HUP`reload config, spawn new workers, stop old onesdeploy new code with the socket held open
`TTIN` / `TTOU`one more / one fewer worker, immediatelytest a capacity change without a restart
`USR1`reopen log fileslog rotation
`QUIT` / `INT`quick shutdownwhen you have accepted the dropped requests
One rolling restart, second by second
  1. t+0s

    Readiness flipped to failing

    the app answers /readyz with 503 while still serving normally — the load balancer stops sending new requests within one check interval

  2. t+5s

    SIGTERM reaches the gunicorn master

    gunicorn begins a graceful shutdown; the listening socket stops accepting, in-flight requests continue

  3. t+5s → t+35s

    The drain window (`--graceful-timeout 30`)

    workers finish what they are serving; nothing new arrives because readiness already failed

  4. t+18s

    Last in-flight request completes

    in practice the drain ends well inside the window — the window is the guarantee, not the plan

  5. t+35s

    Stragglers force killed

    gunicorn: workers still alive after the timeout "are force killed" — anything still running is lost here

  6. t+45s

    systemd `TimeoutStopSec` would fire

    deliberately later than 35s: if it fired first, systemd would send the kill and the drain would never finish

  1. t+0s: Readiness flipped to failing — the app answers /readyz with 503 while still serving normally — the load balancer stops sending new requests within one check interval
  2. t+5s: SIGTERM reaches the gunicorn master — gunicorn begins a graceful shutdown; the listening socket stops accepting, in-flight requests continue
  3. t+5s → t+35s: The drain window (`--graceful-timeout 30`) — workers finish what they are serving; nothing new arrives because readiness already failed
  4. t+18s: Last in-flight request completes — in practice the drain ends well inside the window — the window is the guarantee, not the plan
  5. t+35s: Stragglers force killed — gunicorn: workers still alive after the timeout "are force killed" — anything still running is lost here
  6. t+45s: systemd `TimeoutStopSec` would fire — deliberately later than 35s: if it fired first, systemd would send the kill and the drain would never finish

Command

bash
--timeout 30 --graceful-timeout 30 --max-requests 1000 --max-requests-jitter 100

`--timeout` (default 30) is a watchdog on *silence*, not a request deadline, and it belongs below the proxy read timeout so the application fails first and leaves a traceback; a request that needs longer is a background task. `--max-requests` bounds leak damage by recycling workers, and it is only safe with `--max-requests-jitter`, or every worker restarts at the same moment. `--graceful-timeout` is the drain window after `TERM`, after which stragglers are force killed — so systemd's `TimeoutStopSec` and the container grace period must both be larger. And `HUP` reloads with the socket held open, which is why a reload is invisible and a restart is not.

Remember

  • `--threads` above 1 on the `sync` worker silently switches you to `gthread` — gunicorn documents the substitution.
  • Match the callable to the worker class: `-k uvicorn_worker.UvicornWorker` always goes with `config.asgi:application`.
  • The database connection ceiling is fleet-wide. Adding a host lowers the per-host allowance for every host.
  • A view that needs longer than `--timeout` is a background task, not a bigger number.

Pro tip

  • Measure RSS per worker with `ps -o rss= -C gunicorn` before dividing memory by it. The number people assume is usually half the real one.
  • `kill -TTIN` and `-TTOU` change the worker count on a live master, which makes a capacity change testable before it is committed to a config file.

Next up

  • CI/CD
  • Deployment Strategies
  • Safe Django Upgrades

Sources

  • Gunicorn settings reference and design guide — gunicorn.org
  • Gunicorn signal handling — gunicorn.org (docs/content/signals.md)
  • Django — How to use Django with Gunicorn — docs.djangoproject.com/en/5.2/howto/deployment/wsgi/gunicorn/
  • Django — How to use Django with Uvicorn — docs.djangoproject.com/en/5.2/howto/deployment/asgi/uvicorn/

Spotted a mistake on this page? Tell us.