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.
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.
# 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.
# 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.
# 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.
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.
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.
# 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.
# 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.
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.
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.
# 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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Random 502s at low load | keepalive below proxy idle | Raise keepalive |
| Workers die under CPU spikes | Blocked loop, missed heartbeat | Offload blocking work |
| OOM kill after hours | Leak plus no recycling | Set max_requests |
| Quota counts drift | Per-worker in-process state | Move 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.
Related
Same track:
- Setting Up FastAPI — the parent guide covering project layout, config, and first deploy.
- FastAPI vs Litestar — framework choice moves throughput less than worker count does.
- FastAPI vs Flask for API Development — why an async core is worth tuning workers around at all.
Other tracks:
- Zero-Downtime Deploys for Python APIs — the full drain-and-swap sequence your graceful timeout plugs into.
- Fixing Connection Pool Exhaustion — because your connection ceiling is workers multiplied by pool size.
- Monitoring and Logging Python APIs — how to see worker restarts and p95 latency before a customer does.