Benchmarks comparing API frameworks using idiomatic, framework-recommended code (no deep optimisations). The question: how fast is each framework under best practices?
Everything runs inside docker. Frameworks are benched one at a time — for a given run, exactly one framework container is ever alive alongside the dependent services (Postgres, the upstream, the k6 runner), so no two frameworks ever share a core or contend for host resources.
Disclosure: I'm also the author of jero, one of the frameworks benchmarked here. Every measure in this repo — equal resource budgets, idiomatic code per framework, isolated single-framework runs, documented intended differences — applies identically to jero and everyone else; nothing here is tuned in its favor. Judge the methodology, not my word for it, and open an issue/PR if you spot a framework being shortchanged.
task bench # every framework, one at a time, best-of-3
task bench -- --frameworks "jero fastapi" # just these two, still one at a time
task bench -- --duration 30s --vus 200 # override k6 knobs (see: uv run yeet ./bench.py --help)
task export-results -- 20260813T120000 # re-print a past run's tablestask bench (bench.py) generates one RUN_ID — the kickoff timestamp — and, for
each name in --frameworks, brings up compose.base.yml (Postgres + the upstream + the
k6 runner) plus that framework's own compose.<fw>.yml, waits for the runner to finish
every test, tears the pair back down, then moves to the next framework. Every
framework's results land in results/bench.db (SQLite) tagged with the shared RUN_ID —
that's what ties a multi-framework run together. At the end, bench.py hands off to
export_results.py to print the comparison tables and write a full report — tables, a
chart SVG, exact run config (VUs/duration/AMI/region if run on EC2), and the methodology
— plus the raw underlying results as JSON, to reports/<run_id>/; re-run it standalone
any time with a past RUN_ID via task export-results.
All knobs are CLI flags only (no env-var fallback) — run uv run yeet ./bench.py --help
for the full list.
Every (framework, test) pair runs RUNS times (default 3) — smoothing out noise —
and the attempt with the highest req/s is kept. That's what the comparison tables use;
every attempt is still in results/bench.db (results.is_best marks the winner) for
transparency.
Other knobs (CLI flags — see uv run yeet ./bench.py --help): --vus (k6 concurrency,
default 100), --runs (attempts per test, default 3), --workers (server workers per
service, default 1), --python-server (granian default, or uvicorn),
--frameworks, --tests.
Each framework is pinned to one dedicated CPU core / 2 GB (compose cpuset); the
infra (Postgres, upstream, k6 runner) runs on other cores. Since only one framework is
ever alive at a time, every framework gets the same core — there's no cross-framework
contention to pin apart (see Fairness).
At the end of a run it prints one table per test — throughput, latency, success rate,
and per-container resource use (CPU/mem + sparklines), rows sorted by req/s with
vs-jero multipliers — and every attempt is recorded in results/bench.db. See
results/FINAL-*.md for examples of the format from earlier (pre-SQLite) runs; a
trimmed row looks like:
1 - GET /info
│ Framework │ req/s │ vs │ mean │ vs │ p99 │ vs │ succ% │ memPk │ cpuAv │ cpu ~ │
│ gin │ ~54.6k │ x1.13 │ 1.78ms │ x1.14 │ 9.23ms │ x1.04 │ 100.00 │ 14M │ 71% │ ▇▇▇▇████████████ │
│ jero │ ~48.3k │ x1.00 │ 2.03ms │ x1.00 │ 9.60ms │ x1.00 │ 100.00 │ 91M │ 90% │ ████████████████ │
| Framework | Language | Server (1 worker) | Outbound HTTP client | DB driver |
|---|---|---|---|---|
| jero, FastAPI, Litestar, Blacksheep, Django Ninja | Python 3.13 | granian ASGI (--loop uvloop) |
pyreqwest | psqlpy |
| Flask | Python 3.13 | granian WSGI | pyreqwest (sync) | psycopg |
| Robyn, Django Bolt | Python 3.13 | built-in Rust server (Bolt: manage.py runbolt) |
pyreqwest | psqlpy |
| Gin | Go | net/http (GOMAXPROCS=1) |
net/http | pgx |
| Elysia | Bun / TS | Bun native | fetch |
Bun SQL |
| Spring Boot | Java 25 | embedded Tomcat, virtual threads | RestClient (Apache HttpClient5) | JdbcTemplate (HikariCP) |
All apps use idiomatic, framework-recommended code (no deep optimisations). The Python
ASGI frameworks share granian as the server so the variable under test is the
framework, not the server (switch to uvicorn with --python-server uvicorn — Robyn and
Django Bolt ship their own servers and ignore this knob). The two Django frameworks query
the shared users table with raw psqlpy — the same driver every other async framework in
this fleet uses — rather than Django's ORM or a different raw driver; either would be
doing different work than everyone else rather than a fair idiomatic difference. (Django
itself still depends on psycopg internally for its own DATABASES config to boot, even
though the benchmarked query path never touches it.)
The whole Python field runs Python 3.13.14 (pinned base image) so Robyn — which
has no manylinux_aarch64 (Linux/arm64) wheel for CPython 3.14 yet, only for 3.13 —
runs natively rather than emulated. (Checked 2026-08-10: Robyn 0.88.0 ships a cp314
wheel for macOS and manylinux x86_64, but still not aarch64 Linux.) Once that wheel
lands, bump the base image back to 3.14.
- GET /info — medium JSON (8 fields, varying types) + a custom
x-response-idUUID header. - POST /movies — JWT-authenticated; validates a 5-field body, returns the movie with a
UUID
idand the authenticateduser. - GET /catalog — fetches a static JSON payload from the fast Rust (axum)
upstreamservice and returns it. Public endpoint; the service-to-service call carries a bearer API key the upstream verifies. With every Python framework on the same Rust HTTP client (pyreqwest), this test differentiates frameworks — historically, a pure-Python client (niquests) capped everyone at ~3k req/s and masked the framework entirely. - GET /users/me — JWT-authenticated; id from the token, the rest from a Postgres
usersrow.
Every JWT-authenticated endpoint (test 2, test 4) decodes the same Authorization: Bearer <token> header by hand with PyJWT — including the two Django frameworks, whose
own JWT integrations (django-ninja-jwt's cookie auth, Bolt's JWTAuthentication tied to
django.contrib.auth.User) would each be doing different work than the rest of the fleet.
The goal is idiomatic per framework, held to equal conditions and made scrutable.
-
One framework alive at a time.
task benchruns each framework in its owndocker composeinvocation (compose.base.yml+ that framework'scompose.<fw>.yml), torn down before the next one starts. Every framework gets an identical, contention-free environment — the same core, the same otherwise-idle host — so absolute numbers are comparable both within a run and across separate runs, so long as the host itself is otherwise idle. (Earlier versions of this benchmark ran the full fleet concurrently, each pinned to its own core; that measures "the framework under fleet-wide contention," a different and reasonable question, but not what this repo now measures — seeresults/FINAL-*.mdfor that era's numbers, which aren't directly comparable to newer isolated runs.) -
One worker each. Every service runs 1 worker: granian
--workers 1, GinGOMAXPROCS=1, Bun's single JS thread, Bolt--processes 1. Flask is synchronous (granian WSGI,--blocking-threads 1). A thread sweep (1/2/4/8) confirmed 1 is fastest on every test — including the I/O-bound ones: on a single capped core the GIL + thread scheduling swamp any I/O-overlap benefit, so more threads only add overhead and memory. Flask trailing on I/O is the honest cost of the sync model on one core, not a handicap we imposed. (granian's default pool is worse still — it sizes to host cores, ignoring the single-core pin, and thrashes.) -
One core each —
cpusetaffinity.WORKERS=1bounds worker processes, not threads: granian's Rust HTTP runtime and GIL-releasing extensions (msgspec, thepsqlpy/psycopgdrivers) run on their own threads and would otherwise spread across cores. Each framework is pinned to a single core, which confines all its threads to one core — the honest apples-to-apples cap. We use affinity (cpuset) rather than a CFS quota (cpus: "1") deliberately: a quota throttles a saturated multi-threaded container at each 100 ms scheduler period, adding tens-of-ms stalls to P99; affinity just says "run only here," so the tail stays clean. Infra (Postgres, upstream, k6 runner) gets its own separate cores so it never bottlenecks the framework under test. Caveat on Apple Silicon (Docker Desktop on Mac):cpusetpins a container to a virtual CPU number inside the Linux VM — it does not choose which physical core that vCPU runs on. Apple Silicon's performance/efficiency cores are heterogeneous, and macOS's own hypervisor scheduler (not Linux, not cpuset) decides which physical core a given vCPU lands on at any moment — it can migrate between a P-core and an E-core run to run. So "every framework pinned to cpuset 0" does not guarantee identical silicon across frameworks or even across repeated attempts of the same one. This is exact only on bare-metal Linux with uniform cores; on Apple Silicon it's a real, unresolved source of noise, not something this harness currently controls for. -
Equal resource budgets. DB pool and outbound-HTTP pool are 64 for every framework/language (Python/Django
db_pool_size, Ginpgx MaxConns+MaxConnsPerHost, BunSQL max+BUN_CONFIG_MAX_HTTP_REQUESTS=64). The Bun fetch cap matters beyond parity: Bun keeps a 64-connection keep-alive pool per host but does not queue overflow — beyond 64 concurrent fetches it opens one-shot connections that flood TIME_WAIT and exhaust the container's ephemeral ports (~28k) within seconds, collapsing the proxy test under sustained load. Capped, Bun queues like the others. -
Equal work, per test — every framework does the same task; only the native tool differs (documented below):
Validates body (test 2) Parses upstream (test 3) JSON serializer jero msgspec Struct typed (msgspec) msgspec FastAPI pydantic typed (pydantic) + response-model re-validate pydantic Litestar msgspec Struct typed (msgspec) msgspec Blacksheep dataclass bind passthrough dict orjson Robyn pydantic (native param) typed (pydantic) stdlib json/ pydanticFlask pydantic typed (pydantic) stdlib jsonDjango Ninja pydantic (Schema) passthrough bytes pydantic Django Bolt msgspec Struct passthrough bytes msgspec Gin ShouldBindJSONstructtyped struct encoding/jsonElysia typebox schema passthrough object Bun native Spring Boot jakarta bean validation (record) typed (Jackson) Jackson Known, intended differences (each framework's real idiomatic behaviour — kept, not normalised away): the JSON serializer column (stdlib json is slower than msgspec/orjson/native — relevant for Flask); FastAPI additionally re-validates the response against its
response_model; Blacksheep/Elysia/Django Ninja/Django Bolt return the upstream payload straight through as bytes/dict rather than a typed model; and the two Django frameworks return snake_case field names (pydantic's/msgspec's default) where the other Python frameworks return camelCase; Spring Boot's Jackson binding coerces a numeric field given as a JSON string (e.g."year": "2020") rather than rejecting it like pydantic/msgspec do. These are visible here so a number is never mistaken for pure framework speed.
Each run also records what the framework cost to hit its numbers: during every k6
attempt the runner polls docker stats (cgroup accounting) on the framework's container.
The report shows, per framework × test:
| column | meaning |
|---|---|
memPk / memAv |
peak / average resident memory (MB) |
cpuAv / cpuPk |
average / peak CPU, as % of one core |
cpu ~ / mem ~ |
Unicode sparkline of the CPU / memory time series (0-to-max scaled) |
Only the framework's own container is measured — Postgres and the upstream are excluded, so you see each framework's own cost.
Because every framework is pinned to one core (
cpuset),cpuAv/cpuPksit ≤100% (one core). Near 100% means the test is CPU-bound on its core; lower means it's I/O-bound (e.g. test 3, waiting on the upstream). Read it alongside throughput as what one core actually delivers. (docker statsneeds ~1–2s per sample, so runs shorter than a few seconds yield too few CPU samples to be meaningful — use-s 5+ for the CPU columns.)
A separate, standalone benchmark: which Python HTTP client is fastest for the outbound
call every framework makes in test 3? One core vs the upstream service, sequential
then concurrent, pyreqwest vs httpx2 vs aiohttp vs niquests.
task bench-http-clients # 10s/client, concurrency 64
task bench-http-clients -- -d 20 -c 128 # override duration / concurrencyBuilds and tears down its own stack (compose.http-clients.yml) — unrelated to task bench.
- Add a
[dependency-groups]entry inservices/python/pyproject.toml. - Add an app module
services/python/apps/<fw>_app.py(or, for a Django-based framework, wire it intoservices/python/django_app/). - Add a stage to
services/python/Dockerfile(FROM base AS <fw>→uv sync --no-default-groups --group <fw>). - Add
compose.<fw>.yml(copy an existing one — e.g.compose.jero.yml— and swap the build target, service name, and any framework-specific env). - Bench it with
task bench -- --frameworks "... <fw>".
Non-Python frameworks (Go/Gin, Bun/Elysia, Java/Spring Boot) have their own services/<fw>/ dir + Dockerfile.