DEV Community

Cover image for Benchmarking Python API Frameworks with Real Workloads: FastAPI vs Litestar vs DRF vs Django Ninja vs Django Bolt
Huy Nguyen
Huy Nguyen

Posted on • Originally published at huynguyengl99.github.io

Benchmarking Python API Frameworks with Real Workloads: FastAPI vs Litestar vs DRF vs Django Ninja vs Django Bolt

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

  1. 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.
  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/1 from both FastAPI and Litestar.
  3. 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.
  4. 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

RPS on /auth/me: Django Bolt 3,008, 2.7x ahead of the rest

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/me is 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 JWTCookieAuth runs in middleware, before dependency injection, so retrieve_user_handler opens its own DB session - that request pays for two connection acquisitions instead of one.

Memory and CPU

Memory and CPU per framework: most 195-260MB, drf-granian 456MB; Bolt lowest CPU at 67%

  • 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

  1. 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.
  2. For raw serialization, Bolt and Litestar lead, then FastAPI, then a big gap.
  3. 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.
  4. 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.
  5. 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)