Part 16 of the Fitz series. Fitz is a compiled language where HTTP, Postgres, JWT and WebSockets are part of the syntax. Follows Part 9 (typed ORM + migrations). Today: I stop hand-waving about performance and actually measure it.
The claim nobody should take on faith
Every language that compiles to a native binary loves to say "zero overhead." It's the easiest thing to write in a README and the hardest thing to back up. Fitz's whole pitch is that HTTP + Postgres live in the core of the language — a pure-Rust Postgres driver compiled into the binary, no libpq, no libpython, no GIL. That should make it fast. But "should" isn't a number.
So the repo ships a reproducible benchmark. Not a synthetic micro-loop — two actual boilerplates that any user can git clone and docker compose up:
| Impl | Boilerplate | Stack |
|---|---|---|
| Fitz ORM | api-postgres-fitz |
Pure Postgres wire driver + native ORM |
| Python | api-postgres-python |
Fitz + from python import + SQLAlchemy 2.x + psycopg2 |
Same Postgres 16, same Docker network, same host, the same three endpoints with the same JSON shape:
-
GET /users— list of 50 rows -
GET /users/{id}— single read by PK -
POST /users— insert
Only the ORM/driver behind them changes. If Fitz is faster, it's the driver — nothing else moved.
The numbers (v0.37.12, median of 3 runs)
Hardware: Intel Core Ultra 7 155H (16 cores), 64 GB RAM, Windows 11 + Docker 29.2.1 (WSL2). Sustained 30s at concurrency 10, measured with oha. Median of 3 runs (local runs swing ±10% with CPU thermals and cache state).
Cold start, image, memory
| Metric | Fitz ORM | Python + SQLAlchemy | Ratio |
|---|---|---|---|
| Cold start (s) | 0.34 | 0.31 | ~tie |
| Image size | 134 MB | 272 MB | 2× leaner |
| Memory peak (MB) | 9.2 | 52.4 | 5.7× more efficient |
The memory number is the one that makes people do a double take: 9.2 MB vs 52.4 MB under sustained load. That's not a warm-idle snapshot — it's the peak while serving thousands of requests per second. Fitz carries a tokio runtime + axum + the driver, and that's it. SQLAlchemy drags a Python interpreter, the ORM's per-row instance machinery, and a connection pool guarded by threading.Lock.
Cold start is a tie now — worth being honest about. Fitz used to boot in 0.14s, but since it started linking OpenTelemetry + tracing + metrics into the HTTP binary, cold start rose to ~0.34s, right next to Python. (It's opt-out with @server(observability=false) if you want the 0.14s back.)
GET /users — list of 50 rows, 30s sustained, c=10
| Metric | Fitz ORM | Python + SQLAlchemy | Speedup |
|---|---|---|---|
| p50 latency (ms) | 3.57 | 31.24 | 8.75× |
| p95 latency (ms) | 5.76 | 56.75 | 9.85× |
| p99 latency (ms) | 8.22 | 72.39 | 8.81× |
| Throughput (RPS) | 2618 | 297 | 8.81× |
GET /users/{id} — single read by PK, 30s sustained, c=10 ⭐
| Metric | Fitz ORM | Python + SQLAlchemy | Speedup |
|---|---|---|---|
| p50 latency (ms) | 2.74 | 21.52 | 7.85× |
| p95 latency (ms) | 4.51 | 44.07 | 9.77× |
| p99 latency (ms) | 6.44 | 61.50 | 9.55× |
| Throughput (RPS) | 3377 | 411 | 8.22× |
Read workloads — the typical shape of a REST API — land around ~8× the throughput at ~8× lower latency, and the tail (p95/p99) actually widens the gap rather than closing it. Fitz's tail is tight because there's no GC pause and no GIL contention serializing request handling.
Why Fitz wins the reads
The pure-Rust Postgres driver is compiled straight into the native binary. A request does: parse the HTTP frame (axum) → build parameterized SQL (no allocation-heavy string juggling) → one round trip to Postgres → deserialize rows into typed structs → serialize JSON. That's the whole hot path, and its runtime overhead is close to nothing.
SQLAlchemy adds layers on every request: SQL compilation on the Python side, a connection pool coordinated with a threading.Lock, and converting each result row into an ORM instance with __init__ per row — all serialized by the GIL. None of that is slow in isolation; it's just work Fitz simply doesn't do.
Why Python isn't ridiculously slow (the honest part)
SQLAlchemy 2.x is genuinely well-optimized, and the GIL only blocks pure Python — not SQL execution, not I/O. For a DB-bound workload, the real bottleneck is usually Postgres, not the language on top. That's exactly why the writes tie:
POST /users — 100 sequential, unique body per request
| Metric | Fitz ORM | Python + SQLAlchemy | Speedup |
|---|---|---|---|
| p50 latency (ms) | 174.69 | 190.23 | 1.09× |
| p95 latency (ms) | 243.97 | 271.75 | 1.11× |
| Throughput (RPS) | 3.28 | 2.98 | 1.10× |
That's a technical tie — and I have to be upfront about why. This isn't measuring server write throughput; it's measuring the client. The bench does a sequential curl loop with a unique email per request, and in Git Bash on Windows each subshell carries ~1s of overhead. The per-request latency being ~equal tells you the bottleneck is Postgres' durable write, not the server. Measuring honest POST throughput needs k6 or wrk+lua with body randomization — that's a future extension, not a number I'll dress up.
The bug that once made Fitz 30% SLOWER than Python
The most instructive part of this whole story: Fitz used to lose.
In an earlier version, GET /users/{id} had a p50 of 43.70 ms — about 30% slower than SQLAlchemy on the exact same query. For a "native binary with a pure driver," that was humiliating and, frankly, a great forcing function to actually profile instead of assume.
The culprit was Nagle's algorithm. The driver sent the five messages of Postgres' Extended Query Protocol (Parse / Bind / Describe / Execute / Sync) as five separate write().await calls. Nagle coalesced small packets and waited for a delayed-ACK — piling ~40 ms onto every parameterized query. The fix was two lines in the driver:
-
set_nodelay(true)on theTcpStream(disable Nagle between client and server). - Batch all five protocol messages into a single
write_all.
Result: GET /users/{id} went from 43.70 ms → ~2.7 ms p50 — a ~16× improvement, flipping the story from "Fitz loses" to "Fitz wins ~8×." It's been stable there ever since.
The lesson is the one benchmarks are actually for: they don't exist to make a graph look good, they exist to catch you being slow. Without a reproducible bench, that 40 ms would still be there.
Reproduce it yourself
cd benchmarks/orm-vs-sqlalchemy
bash run.sh
The script spins up each boilerplate with docker compose up -d --build, waits for the first 200, seeds users, benches each endpoint with oha, samples memory via docker stats, and writes a summary.md. For publishable numbers, run it three times and take the median — the headline ratios (5.7× memory, ~8× reads) are stable across runs; only the decimals move.
What I'm not claiming
- Reads, not writes. The win is on read-heavy workloads. POST throughput is a client-side artifact of this bench, not a server measurement.
- Median, not best-case. Absolute numbers shift with the machine's load. The ratios are what hold.
- No JOIN-heavy queries yet. Eager loading, aggregations, and window functions have different profiles. There's a separate mixed-workload bench (Fitz vs Python vs Node) for the fuller picture.
If you want the raw driver internals — wire protocol v3.0, SCRAM-SHA-256, the connection pool — they're all in src/db.rs, one file, no libpq. That's the whole point: the thing that makes it fast is a thing you can read.
Next up in the series: more of the stack that's built into the language, and the honest failure modes I keep finding while building real products with it.
Top comments (0)