Most Python framework benchmarks measure "hello world" JSON. Real APIs do authentication,
database access and complex queries - so that's what I benchmarked, across FastAPI,
Litestar, Django REST Framework, Django Ninja and Django Bolt, each with multiple
production servers, under strict Docker resource limits.
Everything is reproducible: https://github.com/huynguyengl99/python-api-frameworks-benchmark
The core design decisions
- Latest versions of everything - Django Bolt 0.10, Django 6.0, FastAPI 0.141, Litestar 2.24, DRF 3.18, Django Ninja 1.6.2.
-
Each framework uses its own native serializer. Feeding Litestar Pydantic models
makes it convert twice (Pydantic then msgspec), so you'd be benchmarking Pydantic
instead of Litestar - a point the Litestar author raised on an earlier version of this
benchmark. Litestar and Bolt use msgspec structs, FastAPI and Ninja use Pydantic, DRF
uses its serializers. The response models are defined twice, field for field, and the
output is byte-identical: 4,796 bytes for
/articles/1from both FastAPI and Litestar. - JWT in an httpOnly cookie for the auth endpoints - the setup I'd actually use in production. Each framework uses its own ecosystem's library instead of something hand-rolled: drf-auth-kit (DRF), django-ninja-jwt (Ninja), AuthX (FastAPI), and the built-in cookie JWT support in Litestar and Bolt.
- Median across container restarts, not repeated runs. Some servers (notably Bolt's Rust worker pool under a 1-CPU cgroup) pick a throughput regime at container startup and hold it, so best-of-N inside one container flatters the lucky starts. Details in the repo's Methodology section.
Setup
- MacBook M2 Pro, 32GB RAM; PostgreSQL 16; 500 articles, 2000 comments, 100 tags, 50 authors
- Each framework alone in a Docker container:
--cpus=1,--memory=750m, one at a time - bombardier, 100 connections, 10s per measurement
- 5 independent container starts per framework, median reported with min-max spread
Endpoints:
| Endpoint | What it measures |
|---|---|
/json-1k, /json-10k
|
Pure serialization |
/db |
10 simple rows |
/articles?page=1&page_size=20 |
Pagination + nested author/tags |
/articles/1 |
Nested author + tags + comments |
/auth/me |
Cookie JWT verify + load user |
/auth/articles |
Cookie JWT + the paginated query |
One fairness detail that matters: all five frameworks load the user row from the DB on
both auth endpoints. Litestar, DRF and Ninja do it as part of authenticating; AuthX and
Bolt's guards only verify the signature, so I made FastAPI and Bolt load the user
explicitly. Otherwise they'd do strictly less work and the comparison would be junk.
Results
RPS, median over 5 container starts, zero errors across all 70 measurements:
| Config | json-1k | json-10k | /db | /articles | /articles/1 | /auth/me | /auth/articles |
|---|---|---|---|---|---|---|---|
| bolt | 38,576 | 19,089 | 1,986 | 208 | 432 | 3,024 | 196 |
| litestar-uvicorn | 31,284 | 24,547 | 1,039 | 246 | 443 | 976 | 193 |
| litestar-granian | 19,006 | 15,166 | 1,180 | 250 | 488 | 1,104 | 210 |
| fastapi-uvicorn | 13,845 | 2,641 | 984 | 224 | 428 | 820 | 193 |
| fastapi-granian | 8,484 | 2,280 | 952 | 201 | 410 | 749 | 199 |
| drf-gunicorn | 3,925 | 3,132 | 282 | 140 | 193 | 261 | 133 |
| drf-granian | 2,703 | 2,200 | 830 | 198 | 321 | 726 | 179 |
| ninja-granian | 1,566 | 1,422 | 680 | 130 | 295 | 610 | 117 |
| ninja-uvicorn | 1,533 | 1,424 | 699 | 126 | 236 | 584 | 114 |
| drf-uvicorn | 1,035 | 973 | 495 | 153 | 234 | 447 | 137 |
The gap collapses once you hit the database
37x between fastest and slowest on /json-1k. 1.9x on /articles/1. If your endpoint
touches PostgreSQL, your framework is not the bottleneck - your queries are.
What authentication actually costs
Comparing /auth/articles against the identical unauthenticated /articles:
| Config | public | authed | cost |
|---|---|---|---|
| fastapi-granian | 201 | 199 | -1% |
| drf-gunicorn | 140 | 133 | -5% |
| bolt | 208 | 196 | -6% |
| drf-granian | 198 | 179 | -10% |
| ninja-uvicorn | 126 | 114 | -10% |
| fastapi-uvicorn | 224 | 193 | -14% |
| litestar-granian | 250 | 210 | -16% |
| litestar-uvicorn | 246 | 193 | -22% |
Roughly 5-20%, cheaper than I expected for "verify a token and load a user". Two things
stand out:
-
Bolt's
/auth/meis 2.7x ahead of everything (3,024 vs 1,104) because it validates the JWT in Rust before Python runs at all. -
Litestar pays the most for a concrete reason: its
JWTCookieAuthruns in middleware, before dependency injection, soretrieve_user_handleropens its own DB session - that request pays for two connection acquisitions instead of one.
Memory and CPU
- Most configs peak at 195-260MB RAM
-
drf-granian is the outlier at 456MB - untuned
--blocking-threads, per the Granian maintainer (see "unaddressed feedback" below) - Nearly everything saturates ~85% of the 1-CPU budget under load - except Bolt at 67%, while leading most endpoints
A word on Django Bolt
If you're open to a young framework, this is the one I'd watch. It won or tied the top
spot on 4 of 7 endpoints while leaving ~18% more CPU headroom than every other config -
room to grow under load, not just a good number on a chart. And unlike moving to Litestar
or FastAPI, you keep the Django ORM, admin and package ecosystem.
Honest trade-offs: it's young and moving fast, the Rust internals (Actix Web + PyO3 +
msgspec) mean you can't monkey-patch your way out of a corner, and under a hard 1-CPU cap
its throughput varies between container starts under a hard 1-CPU cap. For a side project or a new
internal service, I'd have no problem reaching for it today.
Feedback I have NOT addressed yet
Being upfront, because it affects one result. The Granian maintainer (u/gi0baro) explained
that drf-granian's memory number comes from me not setting --blocking-threads or
backpressure, so it spawns a lot of threads and burns time on GIL contention - drf-granian's
456MB is that same unfixed issue. He also noted that --cpus=1 in Docker is a time-slice
scheduler limit rather than a real core pin, and that it penalizes Granian more than other
servers since Granian runs I/O in a separate runtime with extra threads.
I'm keeping the CPU cap because it makes runs reproducible and comparable, but he's right
on both counts. Tuning --blocking-threads is top of my list for the next iteration, along with cold
start time and image size (both suggested by the Litestar author).
Takeaways
- Framework choice barely matters once you touch I/O. Optimize your queries - the N+1 you didn't notice costs more than any framework migration will ever save.
- For raw serialization, Bolt and Litestar lead, then FastAPI, then a big gap.
- Cookie JWT auth costs 5-20% on a DB-heavy endpoint; where the token is validated (Rust vs Python, middleware vs dependency) decides where in that range you land.
- uvicorn vs granian is workload-dependent: uvicorn wins CPU-bound JSON on ASGI frameworks, granian wins DB-bound endpoints, and granian clearly beats uvicorn for WSGI DRF.
- Measure across container restarts and publish the spread - it changed these results more than any framework upgrade did.
Everything - code, Docker setup, runner, raw per-framework JSON results, and dated
snapshots - is in the repo:
https://github.com/huynguyengl99/python-api-frameworks-benchmark
Issues and PRs are very welcome, especially if you know these servers better than I do.
And if you find it useful, a star would be appreciated 😄


Top comments (0)