What Gunicorn and Uvicorn actually do in a FastAPI deployment
Uvicorn is an ASGI server. Each Uvicorn process runs one event loop on one thread, accepts connections, parses HTTP, and calls your FastAPI app. Gunicorn is a pre-fork process manager. It does not speak ASGI itself, but it can host Uvicorn's worker class through -k/--worker-class, so Gunicorn handles forking, signals, timeouts and restarts while Uvicorn runs the loop inside each child. Uvicorn's own --workers flag is a smaller built-in supervisor that spawns and watches child processes without Gunicorn's broader set of controls.
In all three setups, the thing serving requests is the same: N independent processes, each with one single-threaded event loop. They share no Python memory, so each process holds its own copy of the app, its own connection pools and its own caches.
A) Gunicorn + Uvicorn workers B) uvicorn --workers N C) Orchestrator, 1 per container
gunicorn master uvicorn supervisor orchestrator (restarts, scaling)
├── worker 1 [event loop] ├── proc 1 [event loop] ├── container 1: uvicorn [loop]
├── worker 2 [event loop] ├── proc 2 [event loop] ├── container 2: uvicorn [loop]
└── worker N [event loop] └── proc N [event loop] └── container N: uvicorn [loop]
The choice is not about Gunicorn versus Uvicorn as servers. It is about two questions: how many loops to run, and what restarts them when they hang or die. In option C, the orchestrator does the supervising.
The worker class has moved across releases. It originally shipped inside Uvicorn and has since been split into a separate package with a different import path. Pin your versions and check the class name against the packages you actually install.
A) Gunicorn + Uvicorn B) uvicorn --workers N C) 1 uvicorn/container
+---------------------+ +---------------------+ +---------------------+
| gunicorn master | | uvicorn supervisor | | orchestrator |
| fork,signal,timeout | | spawn, respawn | | restart, scaling |
| -k <worker class> | | built into uvicorn | | health checks |
+---------------------+ +---------------------+ +---------------------+
| | | | | | | | |
v v v v v v v v v
+-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+
| w1 | | w2 | | wN | | p1 | | p2 | | pN | |ctr1 | |ctr2 | |ctrN |
|loop | |loop | |loop | |loop | |loop | |loop | |loop | |loop | |loop |
+-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+
Each small box = one OS process, one thread, one event loop.
No shared Python memory: each holds its own app copy, pools and caches.
The real choices: how many loops (N), and who supervises them (top box).
All three deployment options run N independent single-threaded event loops and differ only in what supervises those processes: a Gunicorn master, Uvicorn's built-in supervisor, or an orchestrator.
How concurrency works inside a single worker
Each Uvicorn worker is one process running one event loop on one thread. That changes the sizing math, because a worker's concurrency depends on how your endpoints are written, not on how many requests are queued at it.
An async def endpoint runs directly on the loop. While it awaits I/O, the loop serves other requests, so one worker can hold hundreds of in-flight connections. Any blocking call in that endpoint holds the loop until it returns. Examples include a synchronous database driver, requests.get, time.sleep, or a heavy JSON parse. During that time, every other request on that worker stalls, including health checks. Under load, these stalls look like latency spikes across the whole worker, and the cause can be hard to spot.
FastAPI runs a plain def endpoint in a bounded threadpool, so blocking I/O there does not freeze the loop. The cost is a thread limit per worker, and every thread contends for the GIL. Mixing both styles gives you two separate ceilings in each worker: loop concurrency for async routes and the thread limit for sync ones.
CPU-bound work is different. The GIL serializes Python bytecode across threads, so the threadpool does not help. Adding workers helps only if the container has spare CPU cores. If the CPU quota is fixed, extra workers just compete for the same cores. Move CPU-heavy work to a ProcessPoolExecutor through loop.run_in_executor, to a task queue, or to a separate service.
The example below shows three versions of the same work: a blocking call inside async def, the same call in a def endpoint, and the work offloaded properly.
import asyncio
from concurrent.futures import ProcessPoolExecutor
from contextlib import asynccontextmanager
from fastapi import FastAPI
def crunch(n: int) -> int:
# Pure-Python CPU work: holds the GIL for the whole computation.
return sum(i * i for i in range(n))
@asynccontextmanager
async def lifespan(app: FastAPI):
# One small pool per Gunicorn worker; these processes count against the CPU quota too.
app.state.pool = ProcessPoolExecutor(max_workers=2)
yield
app.state.pool.shutdown()
app = FastAPI(lifespan=lifespan)
@app.get("/bad")
async def bad(n: int = 5_000_000):
# Runs on the event loop thread: every other request on this worker,
# including health checks, waits until crunch() returns.
return {"result": crunch(n)}
@app.get("/threaded")
def threaded(n: int = 5_000_000):
# Runs in FastAPI's threadpool: the loop is not blocked outright, but this
# thread still competes with the loop for the GIL, so latency still suffers.
return {"result": crunch(n)}
@app.get("/offloaded")
async def offloaded(n: int = 5_000_000):
# Runs in a separate process with its own GIL: the loop keeps serving requests.
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(app.state.pool, crunch, n)
return {"result": result}
The same CPU-bound function called directly inside async def (stalls the worker's event loop), inside a plain def endpoint (moved to the threadpool but still contending for the GIL), and offloaded to a ProcessPoolExecutor via run_in_executor.
Why (2 x CPU) + 1 is the wrong starting point for worker count
Gunicorn's documentation suggests (2 x $num_cores) + 1 as a starting point, and that advice made sense for the workloads it was written for. A synchronous worker handles one request at a time and sits idle while it waits on a socket, a disk read, or a database query. Running roughly two workers per core lets one compute while another is blocked, and the extra one covers stragglers.
An async worker doesn't have that idle gap. A single Uvicorn worker overlaps many in-flight requests on one event loop, so I/O waits are already filled with other work inside the process. The remaining limit is CPU time. That makes one worker per available core a reasonable starting point.
Containers add a trap. os.cpu_count() reports the host's cores, not your cgroup CPU quota. On a 32-core node with a 2-CPU limit, an auto-derived formula starts 65 workers that compete for two CPUs' worth of time. The kernel then throttles the container for the rest of each scheduling period, which shows up as latency spikes rather than as an obvious error.
Workers beyond the core count rarely add throughput. They mostly add:
- context switching
- per-process memory and duplicated in-process caches
- extra database and HTTP connection pools
- longer warm-up after every deploy or restart
The snippet below derives the worker count from the CPU limit the process can actually use:
- On cgroup v2, it reads
cpu.max. - On cgroup v1, it reads
cpu.cfs_quota_usandcpu.cfs_period_us. - It treats
maxor-1as unlimited. - It rounds partial CPUs up to at least one.
- It caps the result at
len(os.sched_getaffinity(0)), the CPUs the process is allowed to run on. - It falls back to
os.cpu_count()if no limit can be read.
import math
import os
def _read(path):
with open(path) as f:
return f.read().strip()
def cgroup_cpu_limit():
"""Return the cgroup CPU quota in CPUs, or None if unlimited or unreadable."""
try: # cgroup v2: "<quota> <period>", quota may be "max"
quota, period = _read("/sys/fs/cgroup/cpu.max").split()[:2]
if quota == "max" or int(period) <= 0:
return None
return int(quota) / int(period)
except (OSError, ValueError):
pass
try: # cgroup v1: quota of -1 means unlimited
quota = int(_read("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"))
period = int(_read("/sys/fs/cgroup/cpu/cpu.cfs_period_us"))
if quota > 0 and period > 0:
return quota / period
except (OSError, ValueError):
pass
return None # no readable limit
def available_cpus():
# CPUs this process may be scheduled on; fall back to cpu_count off Linux
if hasattr(os, "sched_getaffinity"):
allowed = len(os.sched_getaffinity(0))
else:
allowed = os.cpu_count() or 1
limit = cgroup_cpu_limit()
if limit is None:
return allowed
# Round partial CPUs up (1.5 -> 2), never below 1, never above affinity
return max(1, min(allowed, math.ceil(limit)))
workers = available_cpus()
A gunicorn.conf.py that sets the worker count from the cgroup v2 or v1 CPU quota, capped by CPU affinity, and falls back to os.cpu_count() when no limit can be read.
Budgeting memory and downstream connections: an illustrative estimate
Every worker is a separate process with its own copy of the app, its own caches and its own connection pools. Nothing is pooled across workers. The total connection count is therefore:
total connections = workers × pool size per worker × replicas
Illustrative estimate (assumed numbers, not a benchmark). Assume a 2-vCPU, 2 GiB container, about 150 MiB RSS per worker, and a database limit of 100 connections shared by 5 replicas.
| Constraint | Calculation | Resulting limit |
|---|---|---|
| CPU | 1 async worker per vCPU | 2 workers |
| Memory | (2048 MiB × 0.75 headroom) ÷ 150 MiB | 10 workers (not binding) |
| DB reserve | Hold back 10 for migrations and admin | 90 usable |
| Pool size | 90 ÷ (5 replicas × 2 workers) | 9 per worker (base + overflow) |
CPU sets the limit at 2 workers. Memory would allow more, which leaves room for request spikes and for the RSS growth described below. If you later raise the worker count, the pool size must drop to keep the total under the database limit. Apply the same formula to Redis and to any other service with a connection cap.
Preload
--preload imports the app in the Gunicorn master before forking. Workers then share those memory pages through copy-on-write. The savings shrink over time. CPython stores reference counts inside each object, so simply reading a shared object writes to its page, and the OS copies that page into the worker. Expect per-worker RSS to drift toward the unshared size, and budget memory for that.
Preload also makes import-time side effects dangerous. Event loops, database pools and background tasks created before the fork are inherited by every worker. The children then share open sockets and loop state that are not safe to use across a fork. Create all of them in the lifespan handler, which runs inside each worker after the fork.
How to implement each option with deliberate operational controls
Gunicorn with the Uvicorn worker class
Run Gunicorn as the process manager and point --worker-class at the Uvicorn worker. That path has moved between packages over time, so check it against your pinned versions before deploying. Set --workers from the budget in the previous section, not from a formula.
--timeout: Each worker sends a heartbeat to the Gunicorn arbiter, which kills and restarts any worker that stays silent past this limit. With the Uvicorn worker, the heartbeat runs on the event loop, so a blocked loop gets the worker killed. That protects you from a frozen process, but a timeout that is too short can also kill legitimate long uploads or streaming responses. Set it above your longest expected blocking stretch.--max-requestswith--max-requests-jitter: These settings recycle a worker after a request count, which caps slow memory leaks. Recycling hides the leak rather than fixing it, so keep tracking memory growth. Without jitter, workers started together reach their limit together and restart at the same time, which causes latency spikes.--graceful-timeout: This is how long a worker gets to drain in-flight requests after a restart signal. Set it to cover your typical slow request and your orchestrator's termination grace period.- Crash recovery: The arbiter replaces any worker that exits, without a restart from outside.
uvicorn --workers
Uvicorn's own supervisor is simpler and exposes fewer of these controls. Check uvicorn --help for your version before you rely on any one of them.
Lifespan handler
In both setups, open the database and HTTP client pools in the startup phase of the lifespan handler and close them in the shutdown phase. This handler runs once per worker, so size each pool knowing the total is the pool size multiplied by the worker count.
gunicorn app.main:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 0.0.0.0:8000 \
--timeout 60 \
--graceful-timeout 30 \
--max-requests 1000 \
--max-requests-jitter 100
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
Gunicorn running FastAPI with the Uvicorn worker class and explicit timeout, graceful-timeout and jittered max-requests settings, followed by the simpler uvicorn --workers equivalent.
Alternatives compared: Gunicorn, Uvicorn --workers, or one process per container
| Criterion | Gunicorn + Uvicorn worker class | uvicorn --workers N |
One Uvicorn process per container |
|---|---|---|---|
| Process management maturity | Long-established arbiter: restarts dead workers, kills hung ones on timeout | Simpler built-in supervisor with fewer controls | None in-process; the orchestrator restarts the container |
| Signal handling | Graceful reload, worker scale up/down by signal | Basic graceful shutdown | Container runtime sends SIGTERM straight to the app |
| Tuning knobs | Timeouts, graceful timeout, max requests with jitter, preload | Worker count and a few limits | Pod resources and replica count |
| Configuration layers | Gunicorn plus Uvicorn plus orchestrator | Uvicorn plus orchestrator | Uvicorn plus orchestrator |
| Memory sharing | Partial copy-on-write with preload | Each worker imports the app separately | None; every pod holds a full copy |
| Probes and metrics | A probe reaches one worker; metrics need multiprocess aggregation | Same as Gunicorn | A probe checks the only process; metrics come from a single registry |
| Best fit | VMs, bare metal | Small hosts, simple services | Kubernetes or similar |
Platform teams on an orchestrator should run one process per pod and let replicas and the HPA replace the process manager. The orchestrator already restarts failed processes and scales them, so a multi-worker container duplicates that job. It also hides worker crashes from the orchestrator, because the pod stays healthy while the arbiter respawns workers inside it.
The cost of this model is a separate interpreter and application copy in every pod. You also have more replicas to schedule, which means more per-pod overhead, more connections per node, and more pressure on downstream pool limits.
Teams on VMs or bare metal benefit from Gunicorn's supervision. Do not run a single process there without an external supervisor such as systemd.
Small deployments where you don't need hung-worker timeouts or request recycling can rely on Uvicorn's built-in supervisor. Skip Gunicorn in that case, because its extra layer adds configuration surface and nothing you would use.
Who should use Gunicorn with Uvicorn workers
This setup fits engineers who run FastAPI services where the process manager is the main thing keeping the service alive:
- Teams deploying to VMs or bare metal, where nothing else restarts a hung or crashed worker. Gunicorn's arbiter, with systemd or a similar supervisor above it, covers that job.
- Teams that need per-worker controls such as heartbeat timeouts, graceful drain windows, and request-count recycling for services with slow memory growth they have not yet fixed.
- Teams running several workers in one large container or host on purpose, for example to share imported code through
--preload, or because the platform charges or schedules per container and fewer, larger units are simpler to run. - Operators who own the full stack: the people who will read Gunicorn's logs, tune its timeouts against the orchestrator's grace period, and handle multiprocess metrics aggregation.
It is less suited to teams whose platform already supervises single processes well, and to developers who just need a local server. For local development, a single uvicorn process with reload is simpler.
When to use it
Use Gunicorn with the Uvicorn worker class when most of these conditions hold:
- You need more than one event loop per host or container, and the host or container has more than one usable CPU after reading the cgroup limit. A single worker can only use one core for Python code.
- No orchestrator restarts individual processes for you, or the orchestrator's unit of restart (the container) is coarser than you want. The arbiter replaces a dead worker without restarting the whole container.
- You want hung-worker detection. If a blocking call can occasionally freeze an event loop, the Gunicorn timeout kills and replaces that worker instead of leaving it stalled.
- You need request recycling or graceful reloads by signal, which
uvicorn --workerseither lacks or exposes with fewer options in your pinned version. - Memory per worker is large enough that copy-on-write sharing from
--preloadmatters, and you have confirmed that nothing with sockets or loop state is created at import time.
When to avoid it
Skip Gunicorn, or reduce to a single worker, in these cases:
- You run on Kubernetes or a similar orchestrator and can scale replicas. One Uvicorn process per pod keeps crashes visible to the orchestrator, makes health probes meaningful, and avoids a second supervision layer. Multi-worker pods hide worker restarts behind a healthy pod.
- The container gets one CPU or less. With one usable core, extra async workers only compete for the same CPU time and add memory and connection pools. Run one process.
- Your bottleneck is a downstream connection limit, not CPU. More workers multiply pool count. If the database budget is already tight, extra workers make pool exhaustion more likely without adding throughput.
- Your workload is CPU-bound Python inside request handlers. More workers only help up to the core count. Past that point, move the work to a process pool, task queue or separate service rather than adding workers.
- The service is small and you don't use timeouts, recycling or preload.
uvicorn --workersor a single process under systemd gives the same result with one less configuration layer. - Your metrics library or in-process state assumes a single process, and you are not ready to set up multiprocess aggregation or move that state to an external store.
Measuring worker count and its impact on cost and engineering time
Sizing is a measurement loop. Run a load test whose mocked or staged dependencies reproduce realistic downstream latency. A stub that returns instantly hides the waiting that async workers exist to overlap. For each run, record:
- Per-worker CPU: a worker near one full core is saturated, and adding concurrency inside it only grows queues.
- Event loop lag: rising lag points to blocking calls or CPU-heavy handlers, which more workers will mask but not fix.
- p99 latency: averages hide queueing, and the tail shows it first.
- cgroup throttling: on cgroup v2,
nr_throttledandthrottled_usecincpu.statshow whether the workers outrun the CPU limit. - RSS per worker: this tells you how many workers fit under the memory limit with headroom.
- Connection pool wait time: if this is non-zero, the database budget is the binding constraint, not CPU.
Change one variable per run: worker count, CPU limit, or pool size. Changing two at once makes the result impossible to attribute.
Sizing from these constraints also lowers cost. Fewer, non-oversubscribed workers stop throttling, so you can set smaller pod requests or choose smaller instances instead of buying CPU to cover contention. A connection budget that is fixed per replica prevents the pool exhaustion that turns a scale-out into a database incident. A single supervision layer means one place to configure timeouts and restarts, which reduces drift between environments and the time spent working out which layer killed a worker.
Reusable checklist
- Confirm the CPU and memory limits the container actually receives.
- Measure idle and loaded RSS per worker.
- Compute replicas × workers × pool size and compare the result against the database connection limit.
- Load test with realistic downstream latency.
- Check for throttling, loop lag and pool wait before changing worker count.
- Repeat this process whenever limits, dependencies or traffic shape change.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to gunicorn uvicorn workers fastapi, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.