Skip to content

Performance / Benchmarks

Four workloads, ten frameworks, one worker on one dedicated core each. Every app, the harness, and the full methodology are public at api-benchmarks.

TL;DR: jero is the fastest Python framework on all four workloads, by 1.03× to 1.27× over Django Bolt and 1.35× to 4.8× over Blacksheep, Litestar, and FastAPI. Per core, it beats Go's Gin on all four and is the fastest of all ten on three of them; only Bun's Elysia is faster, on the pure framework path.

Benchmark results: jero is the fastest Python framework across all four workloads

Each panel is scaled to its own fastest framework; the labels show absolute throughput.

Setup

Setting Value
Machine Amazon EC2 c9g.2xlarge spot, eu-central-1, one framework at a time
Machine specs AWS Graviton5 (arm64), 8 vCPUs (physical cores, no SMT), 16 GiB
CPU 1 dedicated core per framework (cpuset affinity)
Workers 1 (Gin at GOMAXPROCS=1, Go's own default on one core; Django Bolt at --processes 1)
Spring Boot Tomcat with virtual threads, JVM defaults (no heap flags)
Load generator wrk 4.2.0, 2 threads on 2 dedicated cores
Concurrency 128 connections, closed loop
Duration 60s per attempt, after an untimed 3s warm-up
Best-of-N 3 attempts, highest throughput kept
Python 3.14.9 (uv's standalone CPython, every Python framework)
Python server peregrine 1.2.0 + uvloop (Flask via peregrine WSGI; Robyn and Django Bolt ship their own Rust servers)
Outbound client lothc 0.3.0 (native transport) for every Python framework
Database driver fastpg for the async Python fleet; psycopg for sync Flask
Pools 64 connections per framework, database and outbound HTTP alike
Versions jero 0.4.0, Django Bolt 0.13.0, FastAPI 0.162.5
Run 20261113T094529, all ten frameworks, api-benchmarks commit da79feb
  • Correctness first. Every framework passed a 100-request body check before load and returned 100% expected statuses under it.
  • Shared libraries. Every Python framework uses the same HTTP client (lothc), JWT decoder (ryjwt), and Postgres driver (fastpg; Django Bolt included, bypassing Django's ORM). jero's author wrote all three, and this benchmark.
  • Native code. The Python stacks do their JSON, JWT, database, and HTTP-client work in C and Rust extensions; Gin uses encoding/json, golang-jwt, and pgx.
  • Otherwise idiomatic. The differences between apps are listed under "Known, intended differences" in the api-benchmarks README.
  • Latency is under full load (128 connections, closed loop), where tails run optimistic.

Results

req/s higher is better; mean and p99 lower is better.

1. GET /info: the pure framework path

Route, build a typed JSON response with a custom header, encode. No I/O.

Framework req/s mean p99 vs jero
elysia (Bun) 144.2k 0.89ms 1.86ms 1.44×
jero 99.9k 1.27ms 1.32ms 1.00×
django-bolt 91.2k 1.40ms 1.43ms 0.91×
gin (Go) 88.1k 1.46ms 3.28ms 0.88×
blacksheep 74.2k 1.72ms 1.77ms 0.74×
litestar 49.2k 2.59ms 2.64ms 0.49×
robyn 43.6k 2.93ms 3.04ms 0.44×
spring-boot (Java) 41.7k 3.06ms 4.86ms 0.42×
fastapi 29.5k 4.32ms 4.45ms 0.30×
flask 26.9k 4.74ms 4.86ms 0.27×

2. POST /movies: JWT auth and body validation

JWT auth, decode and validate a five-field body, return it with a new id, 201. No I/O.

Framework req/s mean p99 vs jero
jero 84.0k 1.51ms 1.57ms 1.00×
django-bolt 81.8k 1.56ms 1.59ms 0.97×
gin (Go) 53.7k 2.39ms 5.07ms 0.64×
elysia (Bun) 40.7k 3.14ms 6.06ms 0.48×
blacksheep 33.6k 3.78ms 3.84ms 0.40×
litestar 30.3k 4.20ms 4.27ms 0.36×
spring-boot (Java) 26.8k 4.77ms 7.31ms 0.32×
robyn 26.7k 4.78ms 4.86ms 0.32×
flask 20.1k 6.35ms 6.49ms 0.24×
fastapi 17.6k 7.26ms 7.44ms 0.21×

3. GET /catalog: the outbound hop

An authenticated call to a Rust upstream, decoded into a typed model and returned.

Framework req/s mean p99 vs jero
jero 51.8k 2.46ms 2.65ms 1.00×
elysia (Bun) 44.8k 2.85ms 4.81ms 0.87×
django-bolt 41.9k 3.05ms 4.03ms 0.81×
gin (Go) 32.7k 3.91ms 7.14ms 0.63×
blacksheep 31.8k 4.01ms 4.29ms 0.62×
litestar 26.6k 4.82ms 7.65ms 0.51×
robyn 23.1k 5.54ms 8.41ms 0.45×
spring-boot (Java) 20.0k 6.40ms 9.80ms 0.39×
flask 16.7k 7.64ms 7.96ms 0.32×
fastapi 11.7k 11.07ms 28.19ms 0.23×

Elysia relays the upstream JSON without decoding it into a typed model.

4. GET /users/me: the database read

JWT auth, then one Postgres row by the token's subject. Gin, Elysia, and Spring Boot use their native drivers.

Framework req/s mean p99 vs jero
jero 46.2k 2.76ms 3.33ms 1.00×
gin (Go) 37.8k 3.39ms 7.14ms 0.82×
django-bolt 36.3k 3.52ms 4.35ms 0.79×
blacksheep 29.7k 4.31ms 5.39ms 0.64×
elysia (Bun) 26.5k 4.82ms 9.34ms 0.57×
robyn 22.1k 5.79ms 8.62ms 0.48×
litestar 22.0k 5.83ms 8.44ms 0.48×
spring-boot (Java) 20.2k 6.33ms 10.29ms 0.44×
fastapi 10.0k 12.93ms 30.44ms 0.22×
flask 9.0k 14.26ms 15.79ms 0.19×

Caveats

  • Only one core was measured. Go and the JVM scale across cores inside one process; Python and Bun add processes, each with its own memory (~40–83M per Python worker here, against Gin's ~10–28M).
  • Spring Boot ran on JVM defaults at ~615–673M resident; a tuned footprint was not measured.
  • A benchmark is not your app. Fixed payloads on an idle host. Measure the workload you care about.

What middleware and CORS cost

Middleware and CORS compile at wiring, so routes they don't cover pay nothing. Relative cost on the in-process hot-path harness (bench.py's POST-echo shape, ~1.3µs/request baseline; in-process numbers exaggerate deltas compared with a real server):

configuration relative cost
no middleware, no CORS 1.00×
wildcard CORS / a constant response_headers block ~1.02×
origin allow-list CORS ~1.15×
on-scope intercept (falling through) ~1.4×
observe ~1.6×
response_headers method ~1.9×

For contrast, one onion-style ASGI wrapper appending a single constant CORS pair costs ~1.3× on every request.

Measure it yourself

One script runs jero, Litestar, BlackSheep, and FastAPI in-process as bare ASGI callables (no server, no sockets) against a hand-rolled raw ASGI app doing the same work, the ceiling for that work. With no server in the loop the gaps run far wider than in the tables above, so read each framework's distance from raw, not its throughput.

curl -LsSf https://raw.githubusercontent.com/RogerThomas/jero/main/benchmarks/micro_bench.py | uv run -

The source is below; save it as micro_bench.py and uv run micro_bench.py to run it locally.

# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "blacksheep",
#     "fastapi",
#     "jero",
#     "litestar",
#     "msgspec",
#     "rich",
# ]
# ///
"""In-process ASGI micro-benchmark: jero vs Litestar vs BlackSheep vs FastAPI vs a
hand-rolled raw ASGI app, all serving the same three-endpoint API.

Every app is driven directly as an ASGI callable — no server, no sockets — so what you
measure is each framework's own per-request overhead: routing, binding/validation, and
serialization. That makes the numbers a per-core ceiling, not a production promise.

Scenarios (identical raw ASGI scopes for every app):
  plain   GET  /ping                      -> tiny JSON body
  bind    GET  /items/{item_id}?limit=10  -> path + query binding, JSON body
  body    POST /items (JSON, 3 fields)    -> body decode/validation, JSON echo

Fairness notes:
  - The "raw ASGI" app uses no framework but still does honest work with msgspec:
    hand-rolled routing, path/query extraction, a typed validating decode of the POST
    body, and a typed encode of every response. It skips everything a framework gives
    you (404/405 semantics, HEAD/OPTIONS, content-type checks, error envelopes).
  - Each framework app is written the way its own documentation recommends.
  - BlackSheep uses its documented msgspec JSON hook (its fastest configuration).
  - Tweak freely: pin versions in the dependencies block above, change N_MEASURE,
    add a framework, add a scenario.
"""

import asyncio
import time

N_WARMUP = 2_000
N_MEASURE = 20_000
BEST_OF = 3

POST_BODY_CAMEL = b'{"name":"gizmo","priceCents":499,"tags":["a","b"]}'
POST_BODY_SNAKE = b'{"name":"gizmo","price_cents":499,"tags":["a","b"]}'


# --------------------------------------------------------------------- harness
def make_scope(method: str, path: str, query: bytes = b"", body: bool = False) -> dict:
    headers = [(b"host", b"bench")]
    if body:
        headers.append((b"content-type", b"application/json"))
    return {
        "type": "http",
        "asgi": {"version": "3.0", "spec_version": "2.3"},
        "http_version": "1.1",
        "method": method,
        "scheme": "http",
        "path": path,
        "raw_path": path.encode(),
        "query_string": query,
        "root_path": "",
        "headers": headers,
        "client": ("127.0.0.1", 1234),
        "server": ("127.0.0.1", 80),
    }


SCENARIOS = [
    ("plain GET", make_scope("GET", "/ping"), False),
    ("path+query GET", make_scope("GET", "/items/abc123", query=b"limit=10"), False),
    ("POST w/ body", make_scope("POST", "/items", body=True), True),
]


async def run_lifespan_startup(app) -> None:
    """Send lifespan.startup and wait for completion (frameworks wire routes here)."""
    started = asyncio.Event()

    async def receive():
        return {"type": "lifespan.startup"}

    async def send(message):
        if message["type"] == "lifespan.startup.failed":
            raise RuntimeError(message.get("message", "lifespan startup failed"))
        if message["type"] == "lifespan.startup.complete":
            started.set()

    task = asyncio.ensure_future(app({"type": "lifespan"}, receive, send))
    await started.wait()
    task.cancel()


async def bench_one(app, scope_template: dict, body: bytes | None) -> float:
    """Seconds for N_MEASURE requests (asserts 2xx on every response)."""

    def make_receive():
        sent = False

        async def receive():
            nonlocal sent
            if body is not None and not sent:
                sent = True
                return {"type": "http.request", "body": body, "more_body": False}
            return {"type": "http.disconnect"}

        return receive

    async def send(message):
        if message["type"] == "http.response.start":
            assert 200 <= message["status"] < 300, f"unexpected status {message['status']}"

    async def one():
        await app(dict(scope_template), make_receive(), send)

    for _ in range(N_WARMUP):
        await one()
    start = time.perf_counter()
    for _ in range(N_MEASURE):
        await one()
    return time.perf_counter() - start


# ------------------------------------------------------- raw ASGI (honest work)
def build_raw():
    import msgspec

    class Ok(msgspec.Struct):
        ok: bool

    class ItemIn(msgspec.Struct, rename="camel"):
        name: str
        price_cents: int
        tags: list[str]

    class Item(msgspec.Struct, rename="camel"):
        id: str
        name: str
        price_cents: int
        tags: list[str]

    encoder = msgspec.json.Encoder()
    item_in_decoder = msgspec.json.Decoder(ItemIn)

    async def send_json(send, payload: bytes) -> None:
        await send(
            {
                "type": "http.response.start",
                "status": 200,
                "headers": [
                    (b"content-type", b"application/json"),
                    (b"content-length", b"%d" % len(payload)),
                ],
            }
        )
        await send({"type": "http.response.body", "body": payload})

    async def app(scope, receive, send):
        method, path = scope["method"], scope["path"]
        if method == "GET" and path == "/ping":
            await send_json(send, encoder.encode(Ok(ok=True)))
        elif method == "GET" and path.startswith("/items/"):
            item_id = path[len("/items/") :]
            limit = 20
            for pair in scope["query_string"].decode("latin-1").split("&"):
                key, _, value = pair.partition("=")
                if key == "limit" and value:
                    limit = int(value)
            item = Item(id=item_id, name="gizmo", price_cents=limit, tags=["a"])
            await send_json(send, encoder.encode(item))
        elif method == "POST" and path == "/items":
            chunks = []
            while True:
                message = await receive()
                chunks.append(message.get("body", b""))
                if not message.get("more_body"):
                    break
            data = item_in_decoder.decode(b"".join(chunks))
            item = Item(id="new", name=data.name, price_cents=data.price_cents, tags=data.tags)
            await send_json(send, encoder.encode(item))
        else:
            raise AssertionError(f"unrouted request {method} {path}")

    return app


# ------------------------------------------------------------------------ jero
def build_jero():
    from jero import BaseApp, Endpoint, Struct

    class Ok(Struct):
        ok: bool

    class ItemPath(Struct):
        item_id: str

    class PageParams(Struct):
        limit: int = 20

    class ItemIn(Struct, rename="camel"):
        name: str
        price_cents: int
        tags: list[str]

    class Item(Struct, rename="camel"):
        id: str
        name: str
        price_cents: int
        tags: list[str]

    class PingEndpoint(Endpoint, path="/ping"):
        async def get(self) -> Ok:
            return Ok(ok=True)

    class ItemEndpoint(Endpoint, path="/items/{item_id}"):
        async def get(self, path: ItemPath, params: PageParams) -> Item:
            return Item(id=path.item_id, name="gizmo", price_cents=params.limit, tags=["a"])

    class ItemsEndpoint(Endpoint, path="/items"):
        async def post(self, json: ItemIn) -> Item:
            return Item(id="new", name=json.name, price_cents=json.price_cents, tags=json.tags)

    class App(BaseApp):
        async def wire(self) -> None:
            self._include_endpoint(PingEndpoint())
            self._include_endpoint(ItemEndpoint())
            self._include_endpoint(ItemsEndpoint())

    return App()


# -------------------------------------------------------------------- litestar
def build_litestar():
    from dataclasses import dataclass

    from litestar import Litestar, get, post

    @dataclass
    class ItemIn:
        name: str
        price_cents: int
        tags: list[str]

    @dataclass
    class Item:
        id: str
        name: str
        price_cents: int
        tags: list[str]

    @get("/ping")
    async def ping() -> dict:
        return {"ok": True}

    @get("/items/{item_id:str}")
    async def get_item(item_id: str, limit: int = 20) -> Item:
        return Item(id=item_id, name="gizmo", price_cents=limit, tags=["a"])

    @post("/items")
    async def create_item(data: ItemIn) -> Item:
        return Item(id="new", name=data.name, price_cents=data.price_cents, tags=data.tags)

    return Litestar(route_handlers=[ping, get_item, create_item])


# ------------------------------------------------------------------ blacksheep
def build_blacksheep():
    from dataclasses import dataclass

    import msgspec
    from blacksheep import Application, FromJSON, get, post
    from blacksheep.settings.json import json_settings

    # BlackSheep's documented hook for a faster JSON codec.
    def msgspec_dumps(obj) -> str:
        return msgspec.json.encode(obj).decode("utf-8")

    json_settings.use(loads=msgspec.json.decode, dumps=msgspec_dumps)

    app = Application()

    @dataclass
    class ItemIn:
        name: str
        price_cents: int
        tags: list[str]

    @dataclass
    class Item:
        id: str
        name: str
        price_cents: int
        tags: list[str]

    @get("/ping")
    async def ping():
        return {"ok": True}

    @get("/items/{item_id}")
    async def get_item(item_id: str, limit: int = 20):
        return Item(id=item_id, name="gizmo", price_cents=limit, tags=["a"])

    @post("/items")
    async def create_item(item: FromJSON[ItemIn]):
        data = item.value
        return Item(id="new", name=data.name, price_cents=data.price_cents, tags=data.tags)

    return app


# --------------------------------------------------------------------- fastapi
def build_fastapi():
    from fastapi import FastAPI
    from pydantic import BaseModel

    class ItemIn(BaseModel):
        name: str
        price_cents: int
        tags: list[str]

    class Item(BaseModel):
        id: str
        name: str
        price_cents: int
        tags: list[str]

    app = FastAPI()

    @app.get("/ping")
    async def ping() -> dict:
        return {"ok": True}

    @app.get("/items/{item_id}")
    async def get_item(item_id: str, limit: int = 20) -> Item:
        return Item(id=item_id, name="gizmo", price_cents=limit, tags=["a"])

    @app.post("/items")
    async def create_item(item: ItemIn) -> Item:
        return Item(id="new", name=item.name, price_cents=item.price_cents, tags=item.tags)

    return app


# ------------------------------------------------------------------------ main
async def main() -> None:
    # (label, builder, needs_lifespan, post_body)
    candidates = [
        ("raw (msgspec)", build_raw, False, POST_BODY_CAMEL),
        ("jero", build_jero, True, POST_BODY_CAMEL),
        ("Litestar", build_litestar, True, POST_BODY_SNAKE),
        ("BlackSheep", build_blacksheep, True, POST_BODY_SNAKE),
        ("FastAPI", build_fastapi, True, POST_BODY_SNAKE),
    ]
    results: dict[str, dict[str, float]] = {}
    for name, builder, needs_lifespan, post_body in candidates:
        try:
            app = builder()
        except ImportError:
            print(f"{name}: skipped (not installed)")
            continue
        if needs_lifespan:
            await run_lifespan_startup(app)
        results[name] = {}
        for scenario, scope, has_body in SCENARIOS:
            body = post_body if has_body else None
            results[name][scenario] = min(
                [await bench_one(app, scope, body) for _ in range(BEST_OF)]
            )

    from rich import box
    from rich.console import Console
    from rich.table import Table

    def compact(rps: float) -> str:
        return f"{rps / 1e6:.2f}M" if rps >= 1e6 else f"{rps / 1e3:.0f}k"

    # Multipliers are throughput relative to jero (or, if jero was removed from
    # the run, relative to the first app benchmarked) — jero = 1.00×, like the
    # `vs jero` column in the tables above.
    base = results.get("jero") or next(iter(results.values()))
    table = Table(
        title="Framework overhead per request",
        caption=(
            f"req/s · µs per request · throughput vs jero (higher is faster)\n"
            f"{N_MEASURE:,} requests per cell, best of {BEST_OF}, "
            f"in-process — no server, no sockets"
        ),
        box=box.ROUNDED,
        title_style="bold",
        caption_style="dim",
        header_style="bold",
        padding=(0, 1),
    )
    table.add_column("framework", style="cyan", no_wrap=True)
    for scenario, _, _ in SCENARIOS:
        table.add_column(scenario, justify="right", no_wrap=True)
    # Rows ordered by req/s, fastest → slowest, like the tables above.
    for name, cells in sorted(
        results.items(),
        key=lambda item: sum(N_MEASURE / elapsed for elapsed in item[1].values()),
        reverse=True,
    ):
        row = ["[bold white]jero[/]" if name == "jero" else name]
        for scenario, _, _ in SCENARIOS:
            elapsed = cells[scenario]
            us = elapsed / N_MEASURE * 1e6
            mult = base[scenario] / elapsed
            figures = f"{compact(N_MEASURE / elapsed):>5}  {us:>5.1f}µs  {mult:>5.2f}×"
            row.append(f"[bold]{figures}[/]" if name == "jero" else figures)
        table.add_row(*row)
    # A fixed width keeps the table identical in any terminal (or piped to a file).
    Console(width=96).print(table)


asyncio.run(main())