Most framework benchmarks measure a hello world route and declare a winner. That tells you which library has the leanest request loop, but it does not tell you what your API will do when it is talking to a real database under real load. So I set up the opposite test: hold the database, the query and the load constant, and change only the framework.
Five frameworks went in. Every one ran the same aggregation against the same 10 million row Postgres table and returned the same JSON. The only thing that varied was the request pipeline: routing, the concurrency model, serialization, and for Django Bolt a Rust HTTP layer.
The short version. On anything database heavy, the framework barely mattered. The things that moved throughput were the query, whether you used raw SQL or an ORM, and which serializer you picked. Framework choice came last.
The setup
A single wide ecommerce events table: 10,000,000 rows, about 1.34 GB, roughly 2.5 million purchase rows, on PostgreSQL 16. Every framework runs one query, byte for byte identical except for the driver's parameter placeholder. Six aggregates including a COUNT(DISTINCT user_id), grouped by category and country, sorted by revenue, top 50.
SELECT category_id, country_id,
count(*)::bigint AS purchases,
sum(amount)::float8 AS revenue,
round(avg(amount), 2)::float8 AS avg_order_value,
count(DISTINCT user_id)::bigint AS unique_buyers,
sum(quantity)::bigint AS units
FROM events
WHERE event_type = 2
AND created_at >= (SELECT max(created_at) FROM events) - make_interval(secs => $1)
GROUP BY category_id, country_id
ORDER BY revenue DESC
LIMIT 50;
A covering index makes this an index only range scan, so Postgres does the same work for everyone. All numeric outputs are cast in SQL, so every framework serializes identical Python types and nobody gets a Decimal versus float advantage. The time window is anchored to the newest row rather than now(), so the row count stays fixed and the benchmark stays reproducible.
- Light window. About 130 rows aggregated, roughly 1.7ms of database time. The framework is the bottleneck, so this exposes the ceiling of each request pipeline.
- Heavy window. About 1,680 rows, roughly 8.8ms of database time. Genuinely database bound, so this exposes stability and tail latency under load.
Fairness controls were strict. Every app got a 4 CPU budget, Postgres got 6, the load generator got 2, and only one framework ran at a time. Load was closed loop: N persistent clients firing back to back over keep alive. The async frameworks share one asyncpg pool, and DRF, the one sync framework, uses a matched psycopg pool. There is no way to give a sync framework an async driver, so that difference is intrinsic and is reported as such, not hidden.
Round 1: raw SQL, each framework's own serializer
On the light workload, where the framework layer is the bottleneck, the spread is real. At 200 concurrent clients:
| Framework | Requests/s | Note |
|---|---|---|
| Litestar | ~3,600 | msgspec plus a lean ASGI pipeline |
| Django Bolt | ~2,690 | tightest p99 in almost every test |
| FastAPI | ~2,010 | best all round balance |
| DRF | ~1,440 | sync, quietly competitive at moderate load |
| Django Ninja | ~1,000 | tail latency exploded past 10s at 1,000 connections |
The more interesting number is tail latency. At 2,000 connections on the heavy workload, Django Bolt's p99 was about 5.1 seconds while the pure Python servers sat between 8.7 and 14 seconds. That is the Rust runtime showing its value: fewer CPU cycles per request, and thousands of clients queued more gracefully.
On the heavy workload everything converged. Once the aggregation costs about 9ms, Postgres on its 6 core budget is the wall, and every framework lands between roughly 650 and 1,090 requests per second. That is the expected result and the honest one.
Round 2: the ORM tax
Raw SQL is not how most people write endpoints, so I reran everything through an ORM. Switching from tuned raw SQL cut peak throughput by roughly 2 to 2.5 times for the fast frameworks. Litestar went from about 3,600 to 1,340, FastAPI from 2,160 to 1,080, Django Bolt from 2,940 to 1,060. The database does the same work. The cost is query construction and turning rows into Python objects on every request.
Then Django's ORM did something the others did not. Running the identical query, Django Ninja managed only about 130 to 180 requests per second with heavy error rates, while Django Bolt sustained about 1,060 with zero errors. Same query, same ORM, completely different outcome. The difference is the request runtime, not the ORM.
Django executes ORM work through a thread sensitive executor, so an async Ninja view effectively serializes database access and sheds load past a few hundred clients. Sync DRF has its own rule: it must run on a WSGI server. DRF on granian in WSGI mode did about 386 requests per second, while the same DRF on uvicorn in ASGI mode collapsed to about 130 with heavy errors.
The Django HTTP cliff
Next I put ten single process instances of each framework behind a Caddy load balancer and pushed concurrency from 4,000 up to 10,000 connections, stopping any fleet that crossed 30 percent failed requests.
FastAPI, Litestar and Django Bolt absorbed 10,000 concurrent connections with zero errors, degrading gracefully as latency climbed. Vanilla Django did the opposite. Django Ninja and DRF on ASGI crashed at 4,000 connections with 80 to 88 percent errors. DRF on granian in WSGI mode crashed at 6,000. Django Bolt, which is also Django, sailed straight through 10,000.
I wanted to be sure this was not just a sync versus async story, so I ran DRF with true async views through adrf. Making the views genuinely async did not fix it. Both still crashed at 4,000 with the same 80 plus percent error rates. The wall is not the view style. It is how Django funnels the request and the ORM through its thread sensitive database layer under a flood of concurrent HTTP requests.
One more twist. The same Django Channels WebSocket path that shares this stack scaled fine, holding around 1,470 to 1,530 messages per second behind the load balancer while the HTTP path collapsed to about 80. The lesson is specific: it is Django's HTTP request plus ORM path that does not scale here, not the ORM query itself.
The realistic app
The most rigorous run was a proper application with five endpoints spanning a range of cost regimes: a fast product lookup, a moderate dashboard, a slow 90 day revenue roll up, a user lookup, and an I/O bound feed.
On the fast endpoint the three async frameworks tied within noise: Django Bolt about 2,726 requests per second, Litestar about 2,660, FastAPI about 2,507. On the slow endpoint every framework flatlined at about 13 requests per second with the app CPU nearly idle, because the 90 day aggregation saturated the database cores. Framework, ORM, serializer, sync or async: none of it changed the number.
Two more effects showed up clearly. The ORM tax ranged from about 1.6 times for FastAPI with SQLAlchemy up to about 5 times for DRF with the Django ORM. The single worst mistake was giving SQLAlchemy a NullPool, one connection per query, which cratered ORM throughput to double digits. Always give the ORM a real pool.
On serializers, msgspec and pydantic v2 were effectively free because both have a Rust or C core. The DRF serializer, which is pure Python, was the real drag on hot paths. So msgspec over pydantic v2 barely matters, while pydantic v2 or msgspec over the DRF serializer matters a lot.
The verdict
On real database workloads the top three, Django Bolt, FastAPI and Litestar, tie. The spread between them is noise. In order of actual impact:
- The query and database cost. A slow endpoint pinned every framework to about 13 requests per second. Fixing or caching the query is the biggest lever by far.
- Raw SQL versus ORM. A 2 to 5 times difference everywhere. And pool your ORM connections.
- The serializer. pydantic v2 and msgspec are free. DRF serializers are not. Keep them off hot paths.
- Framework choice. Last, and a tie among the async three.
If you want a single recommendation: FastAPI for the best all round balance, Litestar for the most performance per core, Django Bolt if you are a Django shop chasing raw throughput, and DRF on a WSGI server if you need to stay on vanilla Django. Never run sync DRF under ASGI.
If this was useful, the fastest way to act on it is not to switch frameworks. Open your slowest endpoint, look at the query, and decide whether it should be cached or rewritten. That is where the throughput is hiding.
