Uvicorn vs Gunicorn: Worker Configuration for Production FastAPI

FastAPI speaks ASGI, so something has to run the event loop and spread requests across your CPU cores. The two answers builders reach for are Uvicorn on its own and Gunicorn acting as a process manager for Uvicorn workers. Getting this layer right is the difference between paying for four cores and using one. It is also the cheapest performance win available to a small commercial API: no rewrite, just a flag and a number. Part of the Setting Up FastAPI guide, this page covers how to size workers against real container limits, keep memory bounded, drain a deploy without dropping requests, and decide which runner earns its keep.

The two jobs: event loop and process supervisor

Separate two jobs before choosing anything. The first is running the ASGI application on an async event loop — accepting connections, awaiting I/O, dispatching to your handlers. The second is supervising multiple worker processes so you can use every core, respawn a crashed worker, and roll out new code without returning connection errors.

Uvicorn does the first job natively. It is the ASGI server that runs your FastAPI app, and since it added a proper multiprocess manager it also does a competent version of the second job through --workers: it spawns child processes, watches them, and restarts one that dies.

Gunicorn does the second job better than anything else in Python. It cannot run async code itself — it is a WSGI server — so the bridge is a worker class that boots a Uvicorn event loop inside each Gunicorn-managed process. Gunicorn owns the process tree; Uvicorn owns the loop. You get mature supervision wrapped around an async core.

One detail that trips people up in 2026: Uvicorn deprecated its bundled uvicorn.workers module and moved the Gunicorn integration into a separate uvicorn-worker package, exposing uvicorn_worker.UvicornWorker. The old import path still resolves on current releases but emits a deprecation warning, so pin the standalone package in new deployments and keep the class name in an environment variable rather than a Dockerfile string you will forget about.

Gunicorn supervising Uvicorn worker processes A reverse proxy forwards requests to a Gunicorn master process, which supervises several UvicornWorker processes, each running its own async event loop and a full copy of the FastAPI app. Reverse proxy TLS, buffering Gunicorn master, one socket no request handling UvicornWorker 1 event loop plus app copy UvicornWorker 2 own DB pool, own memory UvicornWorker N recycled on max_requests

The commands you actually run

For local development, one process with auto-reload is all you want. Never carry --reload into production: it runs a filesystem watcher, disables multiple workers, and restarts your app on a stray editor swap file.

Bash
# Local dev: one process, hot reload, single loop
uvicorn app.main:app --reload --host 127.0.0.1 --port "${PORT:-8000}"

For production on Uvicorn alone, drop --reload and pass workers explicitly. Uvicorn defaults --workers to the WEB_CONCURRENCY environment variable when it is set, and Gunicorn honours the same variable, so one platform setting drives both runners. Pass it anyway — an explicit flag beats an implicit default when you are reading a crash log at midnight.

Bash
# Production on Uvicorn's own process manager
uvicorn app.main:app \
  --host 0.0.0.0 \
  --port "${PORT:-8000}" \
  --workers "${WEB_CONCURRENCY:-4}" \
  --timeout-graceful-shutdown "${GRACEFUL_TIMEOUT:-25}"

For Gunicorn, point it at the worker class and keep every other knob in a config file so the command line stays short and reviewable.

Bash
# Production with Gunicorn as the supervisor
gunicorn app.main:app --config gunicorn.conf.py

Two things matter in the app import string. It must be a string, not an imported object — Uvicorn cannot fork an already-instantiated app and silently falls back to one worker. And install the speed extras, uvicorn[standard], so you get uvloop and httptools; that alone is worth 10 to 20 percent throughput on JSON endpoints for zero effort.

Process trees for three ways of running the same ASGI app A single Uvicorn process uses one core, Uvicorn with workers spawns children under a parent with few lifecycle controls, and Gunicorn supervises the same children with recycling and graceful timeouts. Three ways to run the same ASGI app uvicorn app:app uvicorn --workers 3 gunicorn + UvicornWorker loop plus app one PID dev, or one vCPU uvicorn parent W1 W2 W3 respawn, no recycling gunicorn master W1 W2 W3 recycling, drain windows Same app, same socket, different lifecycle controls

Sizing workers to vCPUs, not to connections

Each worker is one process running one event loop, and one event loop saturates roughly one core. Worker count therefore tracks cores, never expected concurrent connections — async concurrency inside a single worker already overlaps thousands of in-flight awaits.

The folklore formula (2 × cores) + 1 comes from Gunicorn's sync workers, where a process blocks for the whole duration of a request. Async workers do not block, so the honest starting range is one to two workers per core. Beyond that you buy context switching and cache pressure, not throughput. Here is what a trivial JSON endpoint with a 5 ms Postgres await does on a 2 vCPU container.

Throughput and p95 latency by worker count on two vCPUs One worker serves about 1,150 requests per second, two serve 2,180, four serve 2,340, and eight fall back to 2,190 while p95 latency more than doubles from 24 to 58 milliseconds. 2 vCPU container, JSON route, 5 ms DB await req/s 1,150 2,180 2,340 2,190 1 worker 2 workers 4 workers 8 workers p95 41 ms p95 22 ms p95 24 ms p95 58 ms Gains stop at two per vCPU; latency gets worse after that

Read the shape, not the absolutes. Going from one worker to two nearly doubles throughput because the second core finally does work. Going from two to four adds five percent. Going to eight loses throughput and doubles the tail latency your customers actually feel. On a $25 a month 2 vCPU instance, four workers is the whole optimisation — there is no eighth-worker trick that saves you an instance, and the honest way to prove it is to price the run with cost per API request. At one million requests a month that box is idle 99 percent of the time and your compute cost per request is a fortieth of a cent; the worker count only starts moving money north of roughly fifty million requests a month.

The other half of sizing is knowing how many cores you really have. Inside a container with a CPU quota, os.cpu_count() reports the host's cores — 64 on a big machine — and you spawn 129 workers into a half-core slice. Read the cgroup quota instead.

Python
# app/concurrency.py — worker count that respects container CPU limits
import os
from pathlib import Path


def available_cpus() -> int:
    """Effective CPU budget: cgroup v2 quota first, then affinity, then cores."""
    quota_file = Path(os.getenv("CGROUP_CPU_MAX", "/sys/fs/cgroup/cpu.max"))
    if quota_file.is_file():
        match quota_file.read_text().split():
            case ["max", _]:
                pass  # no quota set, fall through to affinity
            case [quota, period] if int(period) > 0:
                return max(1, round(int(quota) / int(period)))
    if hasattr(os, "sched_getaffinity"):
        return len(os.sched_getaffinity(0))
    return os.cpu_count() or 1


def worker_count() -> int:
    """One to two workers per effective vCPU, overridable by the platform."""
    return int(os.getenv("WEB_CONCURRENCY", str(max(2, available_cpus() * 2))))

Wire that into a Gunicorn config file so ops can retune throughput on any host without a code change.

Python
# gunicorn.conf.py — every knob sourced from the environment
import os

from app.concurrency import worker_count

bind = f"0.0.0.0:{os.getenv('PORT', '8000')}"
workers = worker_count()
worker_class = os.getenv("WORKER_CLASS", "uvicorn_worker.UvicornWorker")

# Watchdog and drain windows
timeout = int(os.getenv("WORKER_TIMEOUT", "30"))
graceful_timeout = int(os.getenv("WORKER_GRACEFUL_TIMEOUT", "25"))
keepalive = int(os.getenv("WORKER_KEEPALIVE", "75"))

# Recycle workers to bound slow memory growth
max_requests = int(os.getenv("MAX_REQUESTS", "2000"))
max_requests_jitter = int(os.getenv("MAX_REQUESTS_JITTER", "200"))

preload_app = os.getenv("PRELOAD_APP", "false").lower() == "true"
accesslog = "-" if os.getenv("ACCESS_LOG", "1") == "1" else None

Memory is the other budget

Every worker holds its own interpreter, its own imported modules, and its own connection pools. Four workers is four copies of pandas, four copies of your Pydantic model registry, and four database pools. On a 512 MB hobby instance that is how you meet the out-of-memory killer, and it is why worker count is bounded by RAM as often as by cores.

Two levers help. preload_app = True imports the application once in the master and forks workers from it, so pages that stay untouched are shared by copy-on-write. Savings are real but partial: CPython writes reference counts into object headers, which dirties shared pages over time, so expect a third to a half off resident memory rather than a clean divide by four.

Resident memory with and without preload for four workers Forking after import gives four fully private 200 megabyte workers totalling 800 megabytes, while preloading shares about 130 megabytes and drops the total to roughly 470 megabytes. Resident memory, four workers, same app preload off 200 MB 200 MB 200 MB 200 MB total 800 MB preload on 130 MB total 470 MB shared after fork private per worker Refcount writes erode sharing as the process ages

Preloading has one hard rule: never open a socket before the fork. A database engine, a Redis client, or an httpx.AsyncClient created at import time gets inherited by every child, and shared file descriptors produce corrupted responses that look like random data-mixing bugs. Create clients in a FastAPI lifespan handler, which runs per worker after the fork. The same discipline keeps you out of trouble when fixing connection pool exhaustion: your real connection ceiling is workers multiplied by pool size, so four workers with a pool of ten is forty Postgres connections from one container before you have scaled to two containers. Trimming the image itself helps too — see optimizing Python Docker image size.

Draining a deploy without dropping requests

This is where Gunicorn earns its dependency. On SIGTERM the master stops accepting new work into old workers, lets in-flight requests finish inside graceful_timeout, and only then kills what is left. Combined with max_requests and its jitter, workers also recycle on a rolling schedule, so a slow leak never grows into a restart at peak.

Timeline of a graceful deploy drain At zero seconds SIGTERM arrives, old workers finish in-flight requests for twelve seconds while new workers boot in six and take over, and the listening socket never closes. SIGTERM old workers exit old workers draining in-flight new workers boot serving the new build listen socket never closed, so no connection refused 0s 10s 20s 30s Keep graceful_timeout under the platform kill window

Two settings must agree with your host. Render gives a container about 30 seconds after SIGTERM before it force-kills; Fly defaults to a much shorter kill timeout unless you raise it. Set graceful_timeout a few seconds below that window, or the platform kills mid-drain and you drop exactly the requests you were protecting. And use exec-form CMD in your Dockerfile, or the shell becomes PID 1, swallows SIGTERM, and no drain happens at all — a detail worth checking against containerizing Python APIs with Docker and the full sequence in zero-downtime deploys for Python APIs.

Dockerfile
# Exec form: gunicorn is PID 1, so SIGTERM reaches it directly
STOPSIGNAL SIGTERM
CMD ["gunicorn", "app.main:app", "--config", "gunicorn.conf.py"]

Failure modes that actually page you

The worst one is silent. Gunicorn's timeout is not a request timeout for async workers — it is a watchdog on a heartbeat the worker writes from inside the event loop. A long await is fine; a blocking call is not. One synchronous requests.get or a CPU-bound loop stops the heartbeat, the master assumes the worker is wedged, and it sends SIGKILL, destroying every other request that worker was serving. That is why blocking work belongs in a thread pool or in background jobs with Celery, not inline.

Worker lifecycle states and the two ways a worker dies A worker boots, serves, and either stops gracefully once max_requests is reached or is killed outright when a blocked event loop misses its heartbeat; the master respawns either way. boot serving max_requests hit heartbeat missed graceful stop in-flight finish SIGKILL in-flight lost respawn new PID, same socket master holds the worker count steady A blocked event loop is killed, never queued

Three more that bite in production. Keep-alive mismatch: if your load balancer idles connections at 60 seconds and keepalive sits at Gunicorn's default of 2, the proxy reuses a connection the worker just closed and your customers see intermittent 502s — set keepalive above the proxy's idle timeout. Streaming responses: server-sent events and token streams hold a worker's connection for minutes, so a fixed worker count caps concurrent streams hard, which is the first thing to check when streaming LLM responses through FastAPI starts stalling. And per-worker state: in-process caches, rate-limit counters, and scheduler jobs all exist once per worker, so four workers means four independent counters unless you move them to Redis.

SymptomLikely causeFix
Random 502s at low loadkeepalive below proxy idleRaise keepalive
Workers die under CPU spikesBlocked loop, missed heartbeatOffload blocking work
OOM kill after hoursLeak plus no recyclingSet max_requests
Quota counts driftPer-worker in-process stateMove state to Redis

Builder verdict

If you run the process yourself on a VM or a plain container, use Gunicorn with the standalone uvicorn_worker.UvicornWorker, drive workers from WEB_CONCURRENCY computed against the cgroup quota, and turn on max_requests, max_requests_jitter, and a graceful_timeout that fits under your platform's kill window. You get Uvicorn's async performance with Gunicorn's operational maturity — recycling, clean drains, bounded memory — for one extra dependency and about twenty lines of config.

If a managed platform already supervises the container, restarts it on crash, and drains it on deploy, skip Gunicorn and run uvicorn --workers with --timeout-graceful-shutdown. The second supervisor buys nothing the host is not doing, and one less layer is one less thing to debug at 3am; the platform comparison in Render vs Railway vs Fly.io tells you which one actually drains properly. Either way, never hardcode the worker count, never let it exceed twice your effective vCPUs, and always check RAM before adding a worker. Framework choice moves throughput far less than this does — a point worth remembering before you spend a week on FastAPI vs Litestar.

FAQ

How much does this cost to run at one million requests a month? Roughly $25 of compute on a 2 vCPU instance, and the worker count barely moves that number — one million requests a month averages under one request per second. Worker tuning starts paying at around fifty million requests a month, where a correct count is the difference between one instance and three.

How many workers should I run for FastAPI? One to two per effective vCPU, then measure. On a 2 vCPU box the gain from two to four workers is about five percent and the gain from four to eight is negative. Compute the count from the cgroup quota, not os.cpu_count(), or a container with a fractional CPU share will spawn dozens of workers.

Do I still need Gunicorn now that Uvicorn manages workers? Only if you want per-worker recycling and tunable drain windows. Uvicorn's manager respawns crashed workers and handles SIGTERM, which covers most managed-platform deployments. Gunicorn adds max_requests, jitter, and graceful_timeout — worth it when you own the host and leaks or long deploys have burned you.

Will more workers stop my 429s and rate-limit errors? No. Workers add throughput, not quota, and per-worker in-process counters actually make limiting less accurate as you scale out. Move counters to Redis and follow the approach in best practices for API rate limiting.

What is the migration risk in changing worker configuration? Low and reversible, provided you change one variable at a time. Ship WEB_CONCURRENCY as a platform setting, watch p95 latency and resident memory for an hour, and roll back by editing the value rather than redeploying code. The genuine risk is preloading with pre-fork sockets, which corrupts responses instead of failing loudly.

Same track:

Other tracks: