Uvicorn, Gunicorn, and Workers
Scenario
You deploy your blazing-fast async FastAPI app using `uvicorn app.main:app`. Under load, it maxes out exactly 1 CPU core at 100%, while the other 7 cores sit completely idle.
Mental model
Uvicorn is a chef working on a single stove (one process, one event loop). No matter how fast they juggle orders asynchronously, they only have two hands. Gunicorn is a restaurant manager that hires N chefs (worker processes) so you can utilize the whole kitchen.
Deep dive
Because of Python's Global Interpreter Lock (GIL), a single Python process can only execute on one CPU core at a time, regardless of how many async tasks or threads it spins up.
Uvicorn is an ASGI server. It runs an event loop in a single process. It handles concurrent I/O brilliantly, but it cannot utilize multiple CPU cores.
To scale across cores, we use Gunicorn as a process manager. Gunicorn forks the application into multiple independent worker processes, each running an instance of Uvicorn. This bypasses the GIL entirely.
**Gunicorn worker classes** — By default, Gunicorn uses synchronous workers that block on I/O. To run an async framework like FastAPI, you must tell Gunicorn to use the Uvicorn worker class (`-k uvicorn.workers.UvicornWorker`). This gives you Gunicorn's multi-process scaling combined with Uvicorn's async event loop inside each process.
Specify the Uvicorn worker class so each Gunicorn process can handle async I/O.
**How many workers?** — The standard heuristic is `(2 x $num_cores) + 1`. For a 4-core machine, you run 9 workers. This ensures that even if some workers are busy or blocked, others are ready to accept new requests. If your app is heavily CPU-bound, a 1:1 ratio with cores might be more efficient.
Run (2 × cores) + 1 workers to maximize throughput.
Code examples
Development Server (Single Core)
uvicorn app.main:app --host 0.0.0.0 --port 8000
This runs one process. Fine for development, but in production, it will bottleneck on a single CPU core.
Production Server (Ground Truth)
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
Gunicorn acts as the master process. `-w 4` creates 4 separate Python processes. `-k` tells them to use the Uvicorn ASGI worker class. 4 event loops, 4 CPU cores utilized.
Common mistakes
- Assuming async bypasses the GIL: `asyncio` allows concurrency for I/O bounds, but it does NOT bypass the GIL. CPU-bound work (like JSON parsing or image processing) in an async endpoint will block the event loop. Multi-processing (workers) is the only way to scale CPU in Python.
Recall questions
- Why does a raw `uvicorn` deployment only utilize one CPU core?
- What role does Gunicorn play when deployed with FastAPI?
- How do multiple worker processes bypass the GIL?
Questions & answers
If you have N Gunicorn workers, how does memory usage scale?
Memory scales linearly (N × memory). Since they are independent processes, they do not share memory space. Every worker loads the entire application and its dependencies into RAM.
A request taking 2 seconds of heavy CPU calculation brings your entire API to a halt. You are using Gunicorn with 4 workers. Why did it halt, and how do you fix it?
CPU-bound work blocks the asyncio event loop. With 4 workers, if 4 users make that request, all 4 event loops are blocked. The fix is to offload the CPU-bound task to a background queue (like Celery/Redis) rather than doing it in the API request lifecycle.
Continue learning
Previous: WSGI vs ASGI