Filter concepts by levelShowing all levels.

Django · Section 94

Gunicorn, Uvicorn, and Deployment Workers

Level
advanced
Read
32 min
Concepts
3

The section is eleven settings and one instruction, and the instruction is the part worth memorising: worker count is constrained by CPU, memory, database connections and workload, not by an arbitrary more-is-better rule. Start by separating two jobs that get conflated. Gunicorn supervises — one master forks N workers, watches them, replaces them, and gives you a single process to signal. The worker class decides concurrency, and its default is `sync`: one request per worker, beginning to end, so capacity is exactly the worker count. That is the right, boring choice behind a buffering reverse proxy, because the proxy already absorbs slow clients and the model is arithmetic rather than hope. `gthread` gives each worker a thread pool, which buys concurrency on I/O and, under the GIL, no CPU throughput at all — gunicorn is explicit that raising `--threads` on `sync` switches you to `gthread` anyway. An ASGI worker runs an event loop, which is the only affordable way to hold WebSockets, SSE or streaming responses; Django documents `python -m gunicorn myproject.asgi:application -k uvicorn_worker.UvicornWorker`, and the callable has to change with the class or you get a confusing failure rather than an obvious one. Async also only pays if the path yields all the way down: one synchronous ORM call inside an `async def` view blocks every connection that loop is serving, which is strictly worse than blocking one sync worker. Then the count. Gunicorn's design guide offers `(2 × cores) + 1` as a starting point and warns that "workers ≠ clients", typically needing only four to twelve workers for heavy traffic — but that formula answers the CPU ceiling alone. Memory gives a second ceiling: free RAM divided by *measured* resident set size, with headroom, because a box that swaps is slower than a box with fewer workers. Database connections give a third, and it is the one that surprises people because it is not local — with `CONN_MAX_AGE` set each worker holds a connection, so the fleet-wide total is workers × hosts plus Celery, against a shared `max_connections`. Workload is the fourth. Take the lowest, and remember that real capacity comes from more instances behind the load balancer, since more workers only redivide one machine. Finally, how a worker ends, which is what decides whether a deploy drops requests. `--timeout` (default 30) is a watchdog on silence rather than a request deadline, and it belongs below the proxy read timeout so the application gives up first and leaves a traceback; a request that needs longer is a background task that has not been written yet. `--max-requests` bounds the damage of a memory leak by recycling workers, and it is only safe alongside `--max-requests-jitter`, because workers started together reach the limit together and restart together. `--graceful-timeout` (default 30) is the drain window after `TERM`, after which stragglers are force killed — so systemd's `TimeoutStopSec` and the container termination grace period must both exceed it, or the platform sends the kill and the graceful shutdown you configured never happens. And `HUP` reloads with the listening socket held open, which is why a reload is invisible to clients and a restart is not.

What is true here

  1. Gunicorn supervises; the worker class decides concurrency.
  2. Worker count is the lowest of four ceilings, not the output of a formula.
  3. The connection ceiling is shared across every host and every tier.
  4. Threads buy I/O concurrency and never CPU throughput.
  5. Grace periods must nest outward, or something else sends the kill.

What you will be able to do

  • Choose a worker class from what the deployment must hold, not from benchmarks
  • Derive a worker count from CPU, memory, connections and workload together
  • Recycle workers for memory without scheduling a periodic outage
  • Configure a restart that drains instead of dropping in-flight requests
One request, and every place the worker configuration decides its fate
-k--workerssilenttoo longafter Nrequestson deployif TimeoutStopSecis too smallfresh worker

nginx

buffers the client · proxy_read_timeout 60s

gunicorn master

owns the socket · forks and supervises N workers

Worker class

sync: one request · gthread: a thread pool · uvicorn: an event loop

How many workers

min(CPU, memory ÷ RSS, DB budget ÷ hosts, 4–12)

The request is served

holding one connection per worker, or per thread

--timeout 30

silence, not duration — killed and replaced, with a traceback

--max-requests + jitter

bounded leak damage, staggered so the instance is never empty

TERM → graceful-timeout

in-flight requests finish; stragglers are force killed

Platform kills first

grace period below graceful-timeout — the drain never completes

  • nginx — buffers the client · proxy_read_timeout 60s
    • leads to gunicorn master
  • gunicorn master — owns the socket · forks and supervises N workers
    • leads to Worker class (-k)
    • leads to How many workers (--workers)
  • Worker class — sync: one request · gthread: a thread pool · uvicorn: an event loop
    • leads to The request is served
  • How many workers — min(CPU, memory ÷ RSS, DB budget ÷ hosts, 4–12)
    • leads to The request is served
  • The request is served — holding one connection per worker, or per thread
    • on error, leads to --timeout 30 (silent too long)
    • leads to --max-requests + jitter (after N requests)
    • leads to TERM → graceful-timeout (on deploy)
  • --timeout 30 — silence, not duration — killed and replaced, with a traceback
  • --max-requests + jitter — bounded leak damage, staggered so the instance is never empty
    • leads to The request is served (fresh worker)
  • TERM → graceful-timeout — in-flight requests finish; stragglers are force killed
    • on error, leads to Platform kills first (if TimeoutStopSec is too small)
  • Platform kills first — grace period below graceful-timeout — the drain never completes

Which server, which worker class

Supervision and concurrency as two decisions, and the callable that has to match.

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.

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

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.

two systemd ExecStart lines from one imagebash
# ---- the ordinary web tier -------------------------------------------
# sync is the documented default worker class: one request per worker,
# beginning to end. Concurrency here is exactly --workers, so capacity
# is a number you choose rather than a behaviour you hope for.
#
# Points at config.wsgi — the WSGI callable.
gunicorn config.wsgi:application \
    --workers 5 \
    --bind unix:/run/gunicorn.sock \
    --timeout 30 \
    --max-requests 1000 --max-requests-jitter 100

# ---- the same image, for streaming endpoints --------------------------
# Gunicorn still supervises; Uvicorn's worker class supplies the event
# loop. Django documents this exact combination.
python -m gunicorn config.asgi:application \
    -k uvicorn_worker.UvicornWorker \
    --workers 3 \
    --bind unix:/run/gunicorn-asgi.sock \
    --graceful-timeout 60

# ---- a middle option, no async code required --------------------------
# gthread gives each worker a thread pool, so a worker waiting on an
# outbound call can serve another request meanwhile. Under the GIL this
# buys I/O concurrency, never CPU throughput.
gunicorn config.wsgi:application -k gthread --workers 4 --threads 4

# NOTE: setting --threads above 1 on the sync worker does not add threads
# to sync; gunicorn documents that "the gthread worker type will be used
# instead". So this line and the one above it are the same thing, with one
# of them stating its intent.
gunicorn config.wsgi:application --workers 4 --threads 4
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

bash
gunicorn config.wsgi:application -k uvicorn_worker.UvicornWorker
# an ASGI server handed a WSGI application

Better

bash
gunicorn config.asgi:application -k uvicorn_worker.UvicornWorker

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

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

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

Together

bash
gunicorn config.wsgi:application                       # sync, the default
gunicorn config.wsgi:application -k gthread --threads 4
python -m gunicorn config.asgi:application -k uvicorn_worker.UvicornWorker

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`

Together

bash
# Django's docs: gunicorn myproject.wsgi "starts one process running one
# thread, listening on 127.0.0.1:8000" — one worker is the default.

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

Advertisement

How many

Four ceilings, the lowest of which is your answer — and one of them is shared.

How many workers, how many threads

coreadvanced

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.

Think of it as

The formula is a starting point, not a decision, and treating it as a decision is how deployments run out of database connections. Think of four independent ceilings and take the lowest. **CPU** gives you the formula: `(2 × cores) + 1`, on the reasoning that a worker blocked on I/O is not using its core, so slightly over-subscribing keeps cores busy. **Memory** gives you a division: free RAM divided by a worker's resident set size, measured on your own box rather than assumed, because a Django worker holding a few hundred megabytes is ordinary and eight of them is gigabytes. Leave headroom — a machine that swaps is slower than a machine with fewer workers. **Database connections** gives you the ceiling people forget, because it is not local to the machine: with `CONN_MAX_AGE` set, each worker holds a connection, so fleet-wide connection count is workers × hosts, plus Celery, against the server's `max_connections`. Scaling workers therefore consumes a shared, finite resource that no single host can see. **Workload** is the fourth, and it is why the range is a range: a CPU-bound view keeps its core busy and gains nothing from over-subscription, while an I/O-bound view leaves the core idle and benefits from more concurrency — though at some point the right answer is threads or async, not more processes. Threads change the arithmetic in one direction only. `threads` defaults to 1 and only affects the `gthread` worker; each thread can hold a request while it waits on I/O, so a worker with four threads can have four requests in flight — but under the GIL they do not execute Python simultaneously. So threads buy waiting capacity cheaply, since they share the process's memory, and buy no CPU throughput at all. The connection arithmetic gets worse, not better: concurrency is workers × threads, and every concurrent request may want its own database connection. Finally, load balancing happens at two levels and it is worth keeping them apart. Inside one host, the kernel distributes accepted connections across workers sharing a listening socket, and you do not tune that. Across hosts, the load balancer distributes and health-checks, which is where horizontal scale actually comes from. The practical consequence is that "we need more capacity" is usually answered by more instances behind the balancer rather than by more workers per instance — because more instances add CPU and memory, while more workers only redivide what one box already had.

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

What we're doing: Derive a worker count from the box and the database rather than from a formula, and encode the reasoning where the next person will see it.

gunicorn.conf.pypython
import multiprocessing
import os

# ---- ceiling 1: CPU ---------------------------------------------------
# Gunicorn's own starting point. Slightly over-subscribed on purpose: a
# worker blocked on the database is not using its core.
cpu_ceiling = multiprocessing.cpu_count() * 2 + 1

# ---- ceiling 2: memory ------------------------------------------------
# RSS_MB is MEASURED on this deployment (ps -o rss= -C gunicorn), not
# guessed. Headroom left deliberately: a box that swaps is slower than a
# box with fewer workers.
usable_mb = int(os.environ["HOST_MEMORY_MB"]) * 0.8
memory_ceiling = int(usable_mb // int(os.environ.get("RSS_MB", 220)))

# ---- ceiling 3: database connections ----------------------------------
# The one that is not local. With CONN_MAX_AGE set, each worker holds a
# connection, so the budget is shared across every host and every tier.
db_budget = int(os.environ["PG_MAX_CONNECTIONS"]) - int(os.environ["PG_RESERVED"])
db_budget -= int(os.environ["CELERY_CONNECTIONS"])
db_ceiling = db_budget // int(os.environ["WEB_HOST_COUNT"])

# ---- ceiling 4: workload ----------------------------------------------
# Gunicorn: "Workers ≠ clients ... typically needs only 4-12 workers to
# handle heavy traffic." Treated as an upper bound, not a target.
workload_ceiling = 12

workers = max(2, min(cpu_ceiling, memory_ceiling, db_ceiling, workload_ceiling))

# Threads only do something on the gthread worker, and they multiply
# concurrency — so they multiply connection demand too. Left at 1 here
# because this tier's views are CPU-bound, where threads buy nothing.
worker_class = "sync"
threads = 1

bind = "unix:/run/gunicorn.sock"
timeout = 30
graceful_timeout = 30
max_requests = 1000
max_requests_jitter = 100
4–7
The formula gunicorn documents, and the only ceiling the formula answers. Everything below exists because the other three are just as capable of binding first.
9–14
Resident memory per worker is measured on the real deployment. Guessing it is how a host ends up swapping, at which point every latency measurement you take is contaminated.
16–21
The shared ceiling. Because connections scale with processes rather than traffic, this is the number that turns a routine worker increase into `FATAL: sorry, too many clients already`.
23–26
Gunicorn's 4–12 guidance treated as a bound rather than a target — the reminder that concurrency is not the same as client count, and that a proxy is already absorbing slow clients.
28–28
One line for the whole decision: the minimum of four ceilings, floored at two so a single wedged worker cannot take the instance out entirely.
30–34
Threads stated explicitly with the reason. On `sync` they would silently switch the worker class to `gthread`, and on a CPU-bound tier they would add connection demand for no throughput.

Why this works: The count is derived from this host and this database rather than from a formula, every input is measured or read from configuration, and the reasoning survives in the file where the next person changes it.

Raising workers without checking the connection budget

Wrong

bash
# "the box has spare CPU, let's go from 5 to 20"
gunicorn config.wsgi:application --workers 20
# x 3 hosts = 60 connections, plus Celery, against max_connections = 100

Better

bash
# Compute the fleet-wide total first, then decide:
#   web:    3 hosts x 9 workers  = 27
#   celery: 2 hosts x 12 workers = 24
#   total 51 against 100, with 20 reserved -> headroom for one more host

What you see: The deploy succeeds, the site is fine for ten minutes, and then `FATAL: sorry, too many clients already` appears across every tier at once — including Celery, which nobody touched.

Why: With `CONN_MAX_AGE` set, a worker holds its database connection between requests, so connection count is a product of processes and hosts and has nothing to do with traffic. Raising workers on one host raises the fleet-wide total by that amount times the host count, and the ceiling it runs into is shared with every other tier — which is why the first thing to break is often Celery rather than the web tier that changed. The arithmetic to do before any worker change is workers × hosts, summed across tiers, compared with `max_connections` minus whatever the database reserves for superusers. When the number cannot be made to fit, a connection pooler in front of the database is the answer, not a larger allowance per worker.

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.

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

Together

bash
# Measure RSS per worker before dividing — do not assume it.
ps -o rss= -C gunicorn | awk '{s+=$1; n++} END {print s/n/1024 " MB avg"}'

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

Together

bash
# 4 workers x 4 threads = up to 16 concurrent requests on ONE process set
gunicorn config.wsgi:application -k gthread --workers 4 --threads 4

Remember: 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.

See also: gunicorn uvicorn and the worker classes · timeouts max requests and graceful restart · worker multiplication and connection exhaustion · looking at a running box

Advertisement

How a worker ends

The silence watchdog, recycling with jitter, and a drain window that nests inside the platform's.

Timeouts, max requests, and restarting without dropping anything

coreadvanced

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

Think of it as

These four settings are all answers to "how does a worker end", and keeping their roles distinct is what stops a deploy from dropping requests. `timeout` is a watchdog. Gunicorn is not measuring how long your view takes; it is measuring silence — a worker that has not checked in for that long is presumed wedged and is killed and replaced. That distinction matters because the fix for hitting it is almost never a bigger number: a request that takes longer than thirty seconds is work that belongs in a background task, and raising the timeout to five minutes converts a fast failure into five minutes of a worker doing something no user is still waiting for. It also has to sit below your reverse proxy's read timeout, so that when something does hang, the application gives up first and you get a log line and a traceback rather than an unexplained 504. `max_requests` is memory hygiene. Python processes accumulate: a leak in a dependency, a cache that grows, fragmentation. Recycling after a fixed number of requests bounds all of it without diagnosing any of it, which is why gunicorn calls it a way to "limit the damage" rather than a fix. Its companion exists because of a failure this introduces: with every worker started at the same instant and counting the same requests, they all reach the limit together and restart together, and for a moment the instance has no workers. `max_requests_jitter` subtracts a random amount per worker, `randint(0, max_requests_jitter)`, so the restarts spread out. Set one without the other and you have built a periodic, self-inflicted outage. `graceful_timeout` is the deploy contract. On `TERM`, gunicorn performs a "graceful shutdown; waits for workers to finish requests up to graceful_timeout", after which stragglers are force killed. So the number is the longest request you are willing to wait for during a restart, and it has to be consistent with everything upstream: systemd's `TimeoutStopSec` and your container platform's termination grace period both have to exceed it, or *they* send the kill and your graceful shutdown never completes. The last piece is `HUP`, which gunicorn documents as "reload configuration, spawn new workers, and gracefully stop old ones" — the master keeps the listening socket the whole time, so a reload is invisible to clients in a way a stop-then-start can never be.

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

What we're doing: Configure the end of a worker's life so leaks are bounded, restarts do not synchronise, and a deploy drains instead of dropping.

gunicorn.conf.py (the lifecycle half)python
# ---- the watchdog -----------------------------------------------------
# Gunicorn: "Workers silent for more than this many seconds are killed and
# restarted." It measures SILENCE, not request duration — and it sits
# below nginx's proxy_read_timeout (60s) on purpose, so a hang is killed
# here, with a traceback, rather than surfacing as a bare 504.
#
# Raising this is almost never the fix. A 60-second request is a
# background task that has not been written yet.
timeout = 30

# ---- the drain window -------------------------------------------------
# After a restart signal, workers get this long to finish in-flight
# requests; gunicorn force kills whatever is still alive afterwards.
#
# systemd's TimeoutStopSec is 45 and the container grace period is 60 —
# both deliberately LARGER, or the platform would send the kill first and
# this graceful shutdown would never complete.
graceful_timeout = 30

# ---- recycling --------------------------------------------------------
# Bounds the damage of a slow leak without diagnosing it: each worker
# exits after ~1000 requests and a fresh one takes its place.
max_requests = 1000

# Mandatory companion. Without jitter every worker hits 1000 at nearly
# the same moment and they all restart together, leaving the instance
# with no workers for a beat. randint(0, 100) spreads them out.
max_requests_jitter = 100

# ---- shutdown hook ----------------------------------------------------
def worker_exit(server, worker):
    # Runs on the way out, including a recycle. Close what the process
    # owns so a recycled worker does not leave a connection behind.
    from django.db import connections
    connections.close_all()
2–5
The watchdog measures silence. It is deliberately below the proxy timeout so the layer that can produce a traceback is the layer that gives up first.
7–9
Stated as policy rather than as a number to tune. Raising `timeout` to accommodate a slow view keeps a worker occupied long after the user has gone.
15–18
The ordering that makes graceful shutdown real: gunicorn 30s, systemd 45s, container 60s. Invert any pair and the outer layer kills a worker that was draining correctly.
21–23
Recycling is damage control, not a fix — gunicorn calls it "a simple method to help limit the damage of memory leaks". It buys time to find the leak without an incident.
25–28
The jitter is not optional. Workers started together and counting the same requests reach the limit together, so without it you have scheduled a small outage at a regular interval.
30–35
`worker_exit` runs on every exit including a recycle. Closing database connections explicitly stops a recycled worker leaving one behind for the server to time out.

Why this works: A hung request dies where it can be explained, leaked memory is bounded without an incident, restarts are staggered, and every layer above gunicorn allows the drain to finish before it intervenes.

Setting `--max-requests` without jitter

Wrong

bash
gunicorn config.wsgi:application --workers 9 --max-requests 1000
# all 9 workers started together, count together, and restart together

Better

bash
gunicorn config.wsgi:application --workers 9 \
    --max-requests 1000 --max-requests-jitter 100

What you see: A latency spike at a regular interval — every couple of hours, always the same shape — with a handful of 502s in the middle of it and nothing in the application log to explain them.

Why: Workers are forked at the same moment and receive requests at roughly the same rate, so their counters advance in lockstep and all of them cross the limit within a few requests of each other. For the moment it takes to fork replacements and re-import the application, the instance has few or no workers, and requests queue or fail. `max_requests_jitter` subtracts `randint(0, jitter)` from each worker's limit, which gunicorn documents as "intended to stagger worker restarts to avoid all workers restarting at the same time". A jitter of ten per cent of the limit is enough to spread them, and the setting is only ever useful alongside `max_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

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

Together

bash
gunicorn config.wsgi:application \
    --timeout 30 --graceful-timeout 30 \
    --max-requests 1000 --max-requests-jitter 100

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

Together

bash
systemctl reload gunicorn      # ExecReload sends HUP
kill -TTIN "$(pgrep -f 'gunicorn: master')"   # +1 worker, right now

Remember: `--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.

See also: how many workers and threads · systemd the journal and cron · reverse proxies load balancers pooling and timeouts · what belongs in a background job

Advertisement