Introduction
When you're building a Python backend, the choice of application server can make or break your deployment. We're not talking about frameworks like Django or FastAPI—we're talking about the software that sits between your application code and the network, handling concurrent requests and managing worker processes. Today on day 67 of our 100-day Python backend development series, we'll explore Gunicorn vs Uvicorn: a Python Backend guide for developers, two servers that dominate Python production deployments but solve fundamentally different problems.
Both are battle-tested in high-traffic e-commerce, SaaS, and ride-hailing platforms. Understanding when to use each—and how to combine them—is essential for production-ready backend systems.
Uvicorn: ASGI server for async frameworks
Uvicorn is a lightweight ASGI (Asynchronous Server Gateway Interface) server built for modern, async-first Python frameworks like FastAPI and Starlette. Unlike its WSGI counterparts, ASGI is an async-aware protocol that handles both HTTP and WebSocket connections natively.
The core strength of Uvicorn is its single-process event loop. When you run Uvicorn, you get a single Python process that uses async/await to handle thousands of concurrent connections efficiently. This works brilliantly for I/O-bound operations—database queries, external API calls, file reads—because your application yields control back to the event loop while waiting for I/O to complete.
Here's a minimal FastAPI example running on Uvicorn:
from fastapi import FastAPI
import asyncio
app = FastAPI()
@app.get("/data")
async def get_data():
# This I/O operation won't block other requests
await asyncio.sleep(1)
return {"status": "ok"}
Running this with Uvicorn directly:
uvicorn main:app --host 0.0.0.0 --port 8000In a production scenario—say, a ride-hailing platform processing incoming ride requests—Uvicorn handles the async nature gracefully. A single Uvicorn process can manage hundreds of concurrent WebSocket connections to driver apps, each waiting for location updates without blocking others.
The limitation: Uvicorn runs as a single process. Python's Global Interpreter Lock (GIL) means CPU-bound work won't parallelize across cores within that process. For that, you need multiple workers.
Gunicorn: process manager for WSGI and ASGI
Gunicorn is a battle-hardened process manager and application server. It spawns multiple worker processes, each running your application independently, and distributes incoming requests across them. Originally built for WSGI (the synchronous gateway interface), Gunicorn now supports ASGI workers too.
Here's the conceptual model: Gunicorn is the master process that listens on a socket, and each worker is a child process handling requests. If a worker crashes, Gunicorn restarts it. If you need to deploy a new version, Gunicorn can gracefully reload workers one at a time without dropping connections.
For a traditional Django application, you'd run:
gunicorn --workers 4 --bind 0.0.0.0:8000 myapp.wsgi:applicationWith 4 workers, Gunicorn spawns 4 separate Python processes, each running your Django app. The kernel's connection queue and Gunicorn's load balancer distribute requests round-robin style. If one worker is handling a slow database query, the other three keep processing requests.
The multi-process architecture brings several advantages: true parallelism for CPU-bound tasks, isolation between workers (a memory leak in one worker doesn't crash others), and fault tolerance. A trade-off is higher memory overhead—each worker is a full Python interpreter.
Gunicorn + Uvicorn workers in production
This is where it gets interesting. Gunicorn can use Uvicorn as a worker class, combining the best of both worlds: Gunicorn's process management and resilience with Uvicorn's async capabilities.
For a FastAPI application in production, the setup looks like this:
gunicorn --workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--max-requests 1000 \
main:appWhat happens here: Gunicorn spawns 4 processes, each running Uvicorn. Each Uvicorn worker manages its own event loop, handling async operations within that process. Gunicorn sits in front, load-balancing across the 4 workers and handling process lifecycle.
Let's say you're running an e-commerce platform handling order processing. Incoming requests land on Gunicorn, which distributes them across 4 Uvicorn workers. Each worker can handle, say, 100 concurrent async connections. That's roughly 400 concurrent request handlers with graceful degradation and fault isolation.
Here's a realistic FastAPI service designed for this setup:
from fastapi import FastAPI, HTTPException
from sqlalchemy.ext.asyncio import create_async_engine
import asyncio
app = FastAPI()
# Async database engine
engine = create_async_engine("postgresql+asyncpg://user:pass@db:5432/shop")
@app.get("/orders/{order_id}")
async def get_order(order_id: int):
async with engine.connect() as conn:
result = await conn.execute(
"SELECT * FROM orders WHERE id = ?", (order_id,)
)
order = result.fetchone()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return {"order_id": order_id, "status": order[1]}
@app.post("/orders")
async def create_order(data: dict):
# Simulate async work
await asyncio.sleep(0.5)
return {"order_id": 123, "status": "created"}
With Gunicorn + Uvicorn workers, each incoming order request might land on a different worker. Within that worker's event loop, database I/O and API calls are non-blocking. Meanwhile, other requests on the same worker (or other workers) progress without waiting.
This configuration is what major platforms use. In a trading system processing market data, you might have a single machine with 8 Uvicorn workers under Gunicorn, each worker handling async streams of price updates. In a travel platform, the same setup handles booking requests and inventory lookups concurrently.
Workers ≈ 2 × cores + 1 as a starting point
Choosing the right number of workers is empirical, but there's a formula that works in practice: workers = (2 × CPU cores) + 1. This is especially true for I/O-bound async applications where workers spend time waiting on network or disk.
For a 4-core machine, start with (2 × 4) + 1 = 9 workers. For an 8-core machine, (2 × 8) + 1 = 17 workers.
Why this formula? Each worker can handle multiple concurrent async operations, but they compete for CPU time when handling CPU-intensive parts (request parsing, response serialization, business logic). By spawning slightly more workers than cores, you ensure that when one worker yields (waiting for I/O), another can use the CPU. The +1 is a buffer for scheduler overhead.
Here's a production Gunicorn configuration file for a 4-core server:
# gunicorn_config.py
import multiprocessing
bind = "0.0.0.0:8000"
workers = (2 * multiprocessing.cpu_count()) + 1
worker_class = "uvicorn.workers.UvicornWorker"
worker_connections = 1000 # Max concurrent connections per worker
max_requests = 1000 # Restart worker after 1000 requests to prevent memory leaks
max_requests_jitter = 100 # Add randomness to avoid thundering herd
timeout = 30 # Kill worker if no heartbeat for 30s
graceful_timeout = 30 # Allow graceful shutdown
keep_alive = 5 # TCP keep-alive timeout
Then run:
gunicorn --config gunicorn_config.py main:appThe max_requests setting is crucial: it prevents memory leaks from accumulating in long-running workers. After 1000 requests, Gunicorn restarts the worker gracefully (finishes in-flight requests first). The jitter prevents all workers from restarting simultaneously.
Important caveat: if your workload is heavily CPU-bound (lots of numerical computation), the formula changes. For CPU-bound tasks, use fewer workers—closer to the actual core count—because more workers will just context-switch and hurt throughput. Most modern Python backends are I/O-bound, so (2 × cores) + 1 is a solid starting point.
Common mistakes
- Running Uvicorn alone in production without a process manager. A single Uvicorn process has no fault tolerance. If it crashes, your service is down. Always wrap it in Gunicorn or a container orchestrator with restart policies.
- Too many workers. 50+ workers on an 8-core machine will waste memory and CPU on context switching. Monitor your actual CPU and memory usage, then tune down.
- Synchronous code in an async framework. If you're using FastAPI with async handlers but calling blocking operations (like synchronous database drivers or CPU-intensive functions), you're defeating Uvicorn's purpose. Use async drivers (asyncpg for PostgreSQL, httpx for HTTP) or push blocking work to a thread pool.
- Ignoring the timeout setting. A slow endpoint that never completes will hang a worker forever. Set a reasonable timeout (e.g., 30 seconds) and add internal request timeouts in your application.
- Not setting max_requests. Python processes can leak memory over time. Without
max_requests, a worker might grow from 50 MB to 500 MB over weeks. Restart workers regularly.
Key takeaways
- Uvicorn is a single-process ASGI server ideal for async frameworks; Gunicorn is a multi-process manager with WSGI/ASGI support.
- In production, combine them: run Gunicorn with Uvicorn workers to get async performance plus process isolation and fault tolerance.
- Start with (2 × CPU cores) + 1 workers for I/O-bound async applications; reduce for CPU-bound workloads.
- Always set
max_requeststo prevent memory leaks andtimeoutto catch hung requests. - Use async drivers (asyncpg, httpx) to avoid blocking the event loop.
- Monitor real worker CPU and memory usage, then tune the count to your actual workload.






Comments
Loading comments…