Benchmarks¶
Numbers from one run, on one machine, benchmarked with
wrk against three minimal apps in the
repo's benchmarks/
directory: Zeython, raw Starlette (no framework, hand-rolled SQLAlchemy
session per request), and FastAPI (the same hand-rolled data access, on
a different framework). Run them yourself before trusting these for
anything that matters — see benchmarks/README.md for exact commands.
Absolute request/sec numbers are only meaningful on the machine that
produced them; what's more likely to transfer is the shape of the
difference between rows.
Environment¶
- 4 vCPU, 15 GB RAM, Linux, single uvicorn process (no
--workers) for every app — a deliberately unglamorous, shared sandbox, not dedicated benchmarking hardware. - Python 3.11.15, FastAPI 0.141.1, Starlette (Zeython's pinned version), SQLAlchemy 2.0.52, uvicorn 0.52.3, wrk 4.1.0.
/json: a static{"hello": "world"}, no database at all — isolates routing/response overhead./items:SELECT * FROM items(100 rows, SQLite viaaiosqlite).
/json — no database involved¶
wrk -t4 -c50 -d10s
| App | Req/sec | Avg latency |
|---|---|---|
| Starlette (raw) | 11,094 | 4.56 ms |
| FastAPI | 7,190 | 6.90 ms |
| Zeython | 4,279 | 11.81 ms |
Zeython is slower than raw Starlette here, and that's expected, not a
bug: DatabaseServiceProvider opens a real database session for
every request — including this one, which never touches the database
— via async with self.database.session(): wrapping the whole request
(see Architecture).
That's the cost of "a session is just always there, no per-route
plumbing" as a default; an app that's mostly non-DB routes and cares
about this specific number could look at making that session lazier, but
today it's unconditional.
/items — a real (if tiny) database query¶
wrk -t4 -c50 -d10s
| App | Req/sec | Avg latency |
|---|---|---|
| Starlette (raw) | 155 | 303 ms |
| FastAPI | 132 | 360 ms |
| Zeython | 104 | 449 ms |
The gap between apps shrinks once real SQLite I/O enters the picture
— all three converge into the same rough band, a long way down from the
/json numbers above. That's not framework overhead disappearing; it's
aiosqlite becoming the bottleneck: each connection is served by a
single background thread, so 50 concurrent requests queue up behind
however many connections are actually open, regardless of which async
framework is asking. At lower concurrency (wrk -t2 -c5 -d8s) all three
land in a tighter 170–256 req/sec band, consistent with that read —
SQLite/aiosqlite under concurrent access, not the web framework, is
what's actually being measured once a query is involved. A real
Postgres/MySQL deployment (see
Connection pooling) would tell a
different story here; this benchmark doesn't attempt one.
Reading this honestly¶
- Zeython pays a real, measurable per-request tax for its request-scoped session default. If your app is overwhelmingly non-database routes at very high RPS, that's a genuine cost to know about going in.
- Once a database query is actually in the request path, the choice of
database/driver dominates far more than which of these three you
picked — don't let the
/jsongap above stand in for "how fast will my actual app be." - This is not a TechEmpower-style exhaustive suite (no pipelining, no
Postgres, no multi-worker
--workers N, one machine, one run). Treat it as a starting point for your own measurement, not a verdict.