What Uvicorn, Gunicorn and Granian actually are

FastAPI does not listen on a socket. It is an ASGI application: an async callable that receives a connection scope plus receive and send channels. Some other program has to accept TCP connections, parse HTTP, translate each request into ASGI calls and send the response back. That program is the server, and this is the layer where these three tools differ.

Uvicorn is an ASGI server built on asyncio. It parses HTTP and WebSocket traffic and drives your app from a single event loop per process. It can start several worker processes itself, but its supervision features are deliberately minimal.

Gunicorn comes from the WSGI world. It is a pre-fork process manager. A master process binds the socket, forks workers, and restarts any worker that dies or hangs. It cannot serve ASGI by itself, so for FastAPI you plug in an ASGI worker class such as Uvicorn's. Gunicorn depends on fork() and does not run natively on Windows.

Granian implements its HTTP handling in Rust and supports ASGI alongside its own interfaces. It has its own worker model, so it does not need Gunicorn on top.

The request path looks like this in every setup:

Client → load balancer / reverse proxy → server or master process → workers (one event loop each) → FastAPI app

Tool Role Speaks ASGI natively Manages worker processes
Uvicorn ASGI server Yes Basic
Gunicorn Process manager No, needs an ASGI worker class Yes, full pre-fork supervision
Granian ASGI server (Rust core) Yes Yes, its own model

These tools are not interchangeable rivals. A common production setup runs Gunicorn and Uvicorn together. The real decision is which layer handles HTTP parsing and which layer supervises processes.

                  ┌───────────────────────────────┐
                  │            Client             │
                  └───────────────┬───────────────┘
                                  │ HTTP(S)
                  ┌───────────────▼───────────────┐
                  │ Load balancer / reverse proxy │
                  └───────────────┬───────────────┘
                                  │ HTTP
 ┌────────────────────────────────▼────────────────────────────────┐
 │ Server / master process: binds socket, supervises workers       │
 │ Gunicorn master | Uvicorn (--workers) | Granian main process    │
 └────────┬───────────────────────┬───────────────────────┬────────┘
          │ fork/spawn            │                       │
 ┌────────▼────────┐     ┌────────▼────────┐     ┌────────▼────────┐
 │ Worker 1        │     │ Worker 2        │     │ Worker N        │
 │ ┌─────────────┐ │     │ ┌─────────────┐ │     │ ┌─────────────┐ │
 │ │ event loop  │ │     │ │ event loop  │ │     │ │ event loop  │ │
 │ └──────┬──────┘ │     │ └──────┬──────┘ │     │ └──────┬──────┘ │
 │        │ ASGI   │     │        │ ASGI   │     │        │ ASGI   │
 │ ┌──────▼──────┐ │     │ ┌──────▼──────┐ │     │ ┌──────▼──────┐ │
 │ │ FastAPI app │ │     │ │ FastAPI app │ │     │ │ FastAPI app │ │
 │ └─────────────┘ │     │ └─────────────┘ │     │ └─────────────┘ │
 └─────────────────┘     └─────────────────┘     └─────────────────┘

Gunicorn + UvicornWorker: Gunicorn supervises, Uvicorn parses HTTP in workers
Uvicorn alone: one tool parses HTTP and does minimal worker supervision
Granian: Rust core parses HTTP; its own worker model supervises processes

The same request path applies to every setup; Uvicorn, Gunicorn and Granian differ only in which of them parses HTTP inside the workers and which supervises the worker processes.

How workers, processes and the event loop work

In all three setups, a worker is one OS process that runs one event loop. Uvicorn's --workers flag starts several of them. Gunicorn forks workers and supervises them. Granian runs Python workers, each with its own event loop, behind an HTTP layer written in Rust. In every case, a single worker can execute Python bytecode on only one core at a time. The GIL makes threads within one interpreter take turns, so the standard way to use eight cores is to run several processes.

Minimal launches:

  • uvicorn app.main:app --workers 4
  • gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4
  • granian --interface asgi --workers 4 app.main:app

Sizing worker count

Start with one worker per available core. Then adjust based on what your endpoints actually do. Mostly I/O-bound apps rarely need more workers, because one event loop already multiplexes thousands of waiting requests. CPU-heavy apps, such as those doing serialization, templating or hashing, gain throughput up to the core count and gain nothing beyond it. In containers, size against the CPU limit, not the host's core count. Test the result under load before you treat it as final.

What each extra worker costs

  • Memory grows roughly linearly, because every process holds its own copy of the app and its imports.
  • Lifespan startup and shutdown run once per worker. Model loads and cache warmups therefore repeat in each process.
  • Connection pools are duplicated in each process. For example, 4 pods × 4 workers × a pool of 20 means up to 320 database connections, which is enough to exhaust a typical Postgres max_connections.

Finally, no server fixes a blocking call inside an async def endpoint, such as a synchronous DB driver or time.sleep. That call stalls the entire loop and every request on that worker. Use async libraries for that work, or move it into a threadpool.

# app/main.py  -- run with: uvicorn app.main:app --workers 4
import asyncio
import os
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI
from starlette.concurrency import run_in_threadpool


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Runs once per worker process: with 4 workers you see 4 different PIDs.
    # A DB pool created here is also created 4 times.
    print(f"startup in worker pid={os.getpid()}")
    yield
    print(f"shutdown in worker pid={os.getpid()}")


app = FastAPI(lifespan=lifespan)


@app.get("/blocking")
async def blocking():
    time.sleep(1)  # Stalls this worker's event loop: no other request progresses.
    return {"pid": os.getpid()}


@app.get("/awaiting")
async def awaiting():
    await asyncio.sleep(1)  # Yields to the loop; other requests keep running.
    return {"pid": os.getpid()}


@app.get("/offloaded")
async def offloaded():
    # Push an unavoidable sync call (e.g. a sync DB driver) to a thread.
    await run_in_threadpool(time.sleep, 1)
    return {"pid": os.getpid()}

A FastAPI app showing that lifespan runs once per worker PID, and that a blocking call in an async endpoint stalls the loop while awaiting or offloading to a thread does not.

Process supervision: what Gunicorn adds and what you give up without it

A worker process can die from a segfault in a C extension, an out-of-memory kill, or an unhandled error during startup. Supervision exists so that one dead process does not quietly reduce capacity until someone notices.

What Gunicorn's master process does

Gunicorn runs a master process that never handles requests. It forks the workers, watches them, and replaces any that exit or stop responding within timeout. It provides two other operational controls:

  • Request-count recycling. max_requests restarts a worker after it has served that many requests. max_requests_jitter randomizes the threshold so workers do not all restart at once. This caps the damage from slow memory leaks in your code or its dependencies without requiring you to find the leak first.
  • Signal-driven reloads. Sending HUP to the master starts fresh workers and gracefully retires the old ones. TERM drains in-flight requests for up to graceful_timeout before the workers exit.

For FastAPI, set worker_class to a Uvicorn worker. Gunicorn then handles supervision and Uvicorn runs the event loop inside each worker. The worker class has moved between packages over time, so check the current Uvicorn and Gunicorn documentation for the exact import path and option names.

What the built-in modes give you

Uvicorn's --workers and Granian's --workers both spawn and manage multiple processes. Uvicorn provides --limit-max-requests. Granian provides respawn and worker-lifetime settings. Whether a crashed worker is restarted automatically, and how reloads behave, has varied between releases, so verify this for the version you deploy.

The trade-off

Gunicorn's supervision has been used in production for a long time. In exchange, it adds a second component to your stack and roughly doubles the configuration surface. Its process model was also designed for WSGI, and some of its settings, such as threads, do not apply to async workers.

# gunicorn.conf.py -- Gunicorn supervises, Uvicorn runs the event loop in each worker.
# Verify option names and the worker class import path against the current
# Gunicorn and Uvicorn docs for the versions you deploy.

bind = "0.0.0.0:8000"

# Number of worker processes the master forks and supervises.
workers = 4

# ASGI worker class. This path is from the uvicorn package; newer setups may
# use the separate uvicorn-worker package ("uvicorn_worker.UvicornWorker").
worker_class = "uvicorn.workers.UvicornWorker"

# Master kills and replaces a worker that stops responding for this many seconds.
timeout = 30

# On TERM (or when old workers retire after a HUP reload), allow in-flight
# requests this long to finish before workers are forced to exit.
graceful_timeout = 30

# Recycle each worker after roughly this many requests to cap slow memory leaks.
max_requests = 1000
# Add a random 0..100 to the threshold so workers don't all restart together.
max_requests_jitter = 100

# Run with:   gunicorn -c gunicorn.conf.py app.main:app
# Reload:     kill -HUP <master_pid>   (starts new workers, retires old ones)
# Shutdown:   kill -TERM <master_pid>  (graceful drain up to graceful_timeout)

A Gunicorn config file that runs FastAPI under Uvicorn workers, with timeouts, graceful shutdown, and jittered max-requests recycling, plus the signals that trigger reloads and shutdown.

Choosing by deployment model: Kubernetes containers vs VMs and systemd

In Kubernetes, the orchestrator already supervises processes. It restarts crashed containers, scales replicas, and routes traffic based on probes. Running one server process per container lets each pod represent one unit of failure and one unit of capacity. If you add Gunicorn inside that pod, you stack two supervisors. A worker that keeps crashing gets silently respawned by the master, so the pod looks healthy and the restart count stays at zero. A liveness probe that hits one worker says nothing about its siblings. On shutdown, SIGTERM has to pass from the container runtime to the master and then to each worker within the grace period. If the entrypoint is a shell wrapper that doesn't forward signals, in-flight requests get cut off.

On a VM managed by systemd, the situation is different. systemd can restart one process, but it won't fan out across cores, recycle leaking workers, or replace a hung worker on its own. A supervising master does all of that.

Environment Recommended Avoid when
Kubernetes or similar orchestrator Uvicorn, one process per container, scale replicas You can't afford many small pods and need multi-core use per pod
VMs or bare metal with systemd Gunicorn with Uvicorn workers You run in an orchestrator that already restarts and scales
Either, when server overhead is measurable Granian Profiling shows time is spent in your app or database, or the team can't absorb a younger ecosystem with fewer operational references

Pick Granian only after profiling shows the server itself is a bottleneck, not because it's faster in general.

Production configuration that applies to all three

Whichever server you pick, the same handful of settings decide whether it behaves well under a real load balancer.

Bind behind a proxy and trust headers narrowly

Bind to 0.0.0.0 inside a container only when a load balancer or ingress sits in front of it. Enable forwarded-header handling, such as Uvicorn's --forwarded-allow-ips or Gunicorn's forwarded_allow_ips, and limit it to your proxy's addresses. If you trust *, any client can spoof X-Forwarded-For and bypass IP-based rate limits or audit logs.

Align keep-alive and timeouts

Set the server's idle keep-alive timeout slightly longer than the load balancer's. If the server closes an idle connection first, the proxy can reuse a socket that is already dead and return intermittent 502s. Keep request timeouts consistent with what the proxy enforces.

Shut down gracefully

On SIGTERM, the server should stop accepting connections and drain in-flight requests. The drain timeout (for example, Gunicorn's --graceful-timeout) must be shorter than the orchestrator's termination grace period. Otherwise SIGKILL cuts requests off mid-flight. Your container entrypoint should exec the server so it runs as PID 1 and actually receives the signal.

Lifespan runs per worker

FastAPI's lifespan handler runs once in each worker process. Four workers means four connection pools, so size each pool against the database's total connection limit.

Protocols and TLS

HTTP/1.1 is enough behind most proxies. HTTP/2 and TLS are usually handled at the edge, and WebSocket support differs between servers. Check each project's current docs before you rely on any of these, because feature support changes between releases.

#!/bin/sh
# entrypoint.sh: container entrypoint for a FastAPI app served by Uvicorn.
# Use it from the Dockerfile in exec form so no extra shell wraps it:
#   ENTRYPOINT ["/app/entrypoint.sh"]
set -eu

# Assumed environment (illustrative): the load balancer's idle timeout is 60s
# and the orchestrator's termination grace period is 30s.
: "${PORT:=8000}"
: "${WEB_CONCURRENCY:=4}"
# List only your proxy/load balancer addresses here. Never use "*".
: "${FORWARDED_ALLOW_IPS:=10.0.0.10,10.0.0.11}"

# The app's FastAPI lifespan handler runs once per worker, so 4 workers
# open 4 DB pools. Size each pool so the total stays under the DB limit.

# exec replaces this shell, so Uvicorn becomes PID 1 and receives SIGTERM.
# Bind to 0.0.0.0 only because a load balancer sits in front of the container.
# Keep-alive (65s) is longer than the LB idle timeout (60s), which avoids
#   502s when the LB reuses a connection the server has already closed.
# The graceful drain (25s) finishes before the 30s grace period ends and
#   the orchestrator sends SIGKILL.
exec uvicorn app.main:app \
  --host 0.0.0.0 \
  --port "$PORT" \
  --workers "$WEB_CONCURRENCY" \
  --proxy-headers \
  --forwarded-allow-ips "$FORWARDED_ALLOW_IPS" \
  --timeout-keep-alive 65 \
  --timeout-graceful-shutdown 25

A container entrypoint that execs Uvicorn as PID 1, trusts forwarded headers only from listed proxy IPs, and sets keep-alive and graceful-shutdown timeouts against an assumed 60s load balancer idle timeout and 30s grace period. Confirm the flag names in the docs for your Uvicorn version.

How to run an honest granian benchmark against Uvicorn and Gunicorn

Point the load test at the application you will actually ship, not a hello-world route. Use real endpoints with production-sized payloads, a database seeded to realistic volumes, and the same worker count, connection pool size and container limits you plan to deploy. Run the load generator on a separate machine. If it shares CPU with the server, the two compete for cores and you end up measuring that contention instead of the server.

To separate server overhead from application latency, record time spent inside your handlers with middleware or tracing, then subtract it from client-observed latency. The remainder is what the server contributes, and it is the only part that changes when you switch servers.

Metric Why it matters
p95 / p99 latency Tail latency drives timeouts and SLO breaches
Throughput at a fixed latency target Peak RPS hides queueing that is already building
Resident memory per worker Sets how many workers fit in a pod or VM
Error rate under load Shows dropped or reset connections that throughput numbers hide

Illustrative estimate. Assume server A adds 0.3 ms per request and server B adds 0.15 ms. On a hello-world route, B looks twice as fast. Now add a 12 ms query, 5 ms of serialization and a 3 ms external call. The totals become 20.3 ms and 20.15 ms, a gap of under 1%. Once the application does real work, that work dominates per-request time, so the gap between servers mostly disappears.

Load tests won't catch behavioural differences. Granian can differ from Uvicorn in header handling, log format and signal handling during shutdown. Run your functional and graceful-shutdown tests against the new server before you migrate.

Impact on cost and engineering time

The largest cost lever is usually worker count rather than server choice. Each worker process holds its own copy of the application in memory and its own database connection pool. Over-provisioning workers multiplies both costs, even when the CPU sits idle.

Illustrative estimate (assumed numbers, not a benchmark):

  • Total DB connections = replicas × workers per replica × pool size per worker
  • Total app memory = replicas × workers per replica × memory per worker

Assume 6 replicas, 4 workers each, a pool size of 10 and 150 MB per worker. That gives 240 connections and 3.6 GB of memory. If each worker uses its own event loop efficiently and you drop to 2 workers per replica, the totals fall to 120 connections and 1.8 GB. That can be the difference between needing a connection pooler or a larger database tier and not needing one.

Engineering time follows the deployment model:

  • Single-process Uvicorn containers leave little server configuration to own. A crash or memory leak shows up as an orchestrator restart, which is easier to trace than a worker silently recycled by a supervisor.
  • Gunicorn saves time on VMs because its behaviour and failure modes are widely documented. In exchange, you maintain another layer of timeouts, worker classes and restart settings.
  • Granian can reduce compute spend when server overhead is a meaningful share of request time. Its smaller community and fewer operational write-ups mean edge cases may take longer to investigate.

Other ASGI servers, such as Hypercorn and Daphne, are worth evaluating on the same terms. Defaults, features and performance characteristics change between releases for all of these servers. Re-check any decision against current documentation and your own measurements rather than older comparisons, including this one.

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 uvicorn vs gunicorn vs granian, 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 .env by 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.