A Fair Benchmark: Rust, Go, Python, Laravel and WordPress
I re-ran my Rustango benchmark with strict parity: same CPUs, pool, queries and HTML for Go, Rust, Python, Laravel and WordPress. Here is what was wrong the first time, the new numbers, and where my own framework loses time.
A few months ago I published a benchmark that put Rustango, my Django-style framework for Rust, next to Go, a Python framework and Laravel. Go won the uncached pages by a wide margin, and I explained it away. This week I went back to it, and the comparison turned out to be unfair in several ways, most of them against Go and some against Rust.
So I rebuilt the harness from scratch, added plain axum and actix-web next to Rustango, added WordPress, and re-ran everything on one day under one set of rules. This post is the result, including the parts that don't flatter my own framework.
- +34% plain Rust vs Go on the uncached index
- 2.2× cached throughput vs Go
- 0.45 ms p99 on cached pages (Go: 2.0–2.4 ms)
- 7 MiB RAM under load (Go: 21 MiB)
- 46% of Rustango's CPU in one function
The workload
Every app serves the same blog: 10 authors, 30 tags, 1 000 posts, 2 600 post-tag links and 10 000 comments, seeded deterministically. Six routes:
/: the latest 20 posts with author, tags and comment count/post/{slug}: one post with author, tags and every comment/tag/{slug}: the latest 20 posts carrying a tag/cachedand/post/{slug}/cached: the same pages from a Redis page cache/compute: sum of every prime below 20 000 by trial division, no database
Ten runtimes: Go (stdlib net/http + pgx), Rustango on axum and on actix-web, plain axum and actix-web with raw sqlx and Go's exact SQL, a Python framework on gunicorn and on Hypercorn, Laravel on php-fpm and on Octane, and WordPress.
What was wrong the first time
Checking the old run line by line found six problems:
- Go ran 12 threads on a 4-CPU quota. The module said
go 1.23, so Go 1.25 skipped its container-awareGOMAXPROCS. Fixing it alone gave Go +37% on the index and +79% on compute. The 47 ms compute p99 I had blamed on garbage collection was CPU throttling. - Rustango ran more queries than Go: 5 instead of 3 on the index, 7 instead of 4 on the tag page, and it loaded every author, every tag and every post body. Matching Go's plan gave +15% on the index and +40% on detail.
- Different pool sizes: Go had 16 connections, Rustango sqlx's default 10.
- The load generator ran on the Mac, so every request went through Docker Desktop's port forwarding.
- "100% success" meant connections, not HTTP 200. The old Hypercorn run had more 500s than good responses: its persistent connections leaked under ASGI until PostgreSQL ran out of slots. Laravel's predis client ran the container out of ports, and Laravel's nginx gzipped its pages, so it sent 700 bytes where everyone else sent 6.4 KB.
- One 10-second run per endpoint, no warm-up, no repeats.
Two fixes, measured
The rules this time
- Every app gets the same 4 CPUs (pinned to cores 0–3) and 2 GB RAM. Go, Tokio and actix-web run exactly 4 workers.
- PostgreSQL, MySQL and Redis run on cores 4–7; the load generator, oha, runs on cores 8–11 inside the Docker network.
- 16 database connections per app. The compiled apps all issue Go's query plan; the Rust ones without an ORM use Go's SQL text verbatim.
- Before any load, every page from every app is compared with Go's HTML. A mismatch stops the run.
- No compression anywhere, so every app sends the same bytes.
- Production settings: OPcache,
php artisan optimize, pooled connections, debug off, release builds with LTO. - 5 s warm-up, then three 20 s runs per endpoint at 50 connections; the median counts, and only HTTP 200 responses count.
The harness
Compiled runtimes
Requests per second, higher is better:
Uncached pages
Same SQL, same HTML, 50 connections
req/s · higher is better- Go
- Rustango
- Rust
Cached pages
Served from a Redis page cache
req/s · higher is better- Go
- Rustango
- Rust
Compute
Primes below 20 000, no database
req/s · higher is better- Go
- Rustango
- Rust
- Rust vs Go with the same SQL is a draw with character. Plain axum beats Go by 34% on the index, Go beats it by 22% on detail, and the tag page is a tie because PostgreSQL is the bottleneck there (3.65 of its 4 cores busy).
- Cached pages are where Rust pulls away: 1.5–2.2× Go's throughput, with a p99 around 0.45 ms against Go's 2.0–2.4 ms.
- Memory: every Rust app runs in 7–8 MiB under load; Go in about 21 MiB.
- actix-web trails axum on uncached pages with a shared sqlx pool and never uses its full 4 CPUs. I don't know why yet.
The part about my own framework
Rustango on axum is 32% slower than raw sqlx on the same server with the same three queries. That's the honest cost of the ORM today, so I profiled it with perf:
Where Rustango's CPU goes
perf, share of app CPU on uncached pages
- 46%Postgres values decoded by trying ~10 types per cell (#2655)
- 6–7%A HashMap with owned keys for every aggregate row (#2661)
- ~2%Querysets rebuilt and compiled on every request (#2662)
- restEverything else
Cached hits are a separate profile: CachePageLayer's JSON + base64 is ~21% of each one (#2656).
| Cost | CPU share | Ticket |
|---|---|---|
| Postgres values decoded by trying ~10 types per cell, each miss building an error | 46% on the index | #2655 |
CachePageLayer stores pages as JSON + base64 | ~21% per cached hit | #2656 |
A HashMap with owned keys for every aggregate row | 6–7% | #2661 |
| Querysets rebuilt and compiled on every request | ~2% | #2662 |
Decoding by the known type
Micro-benchmark of the fix for #2655
Almost half the CPU goes to one function guessing column types that PostgreSQL already reports. Decoding once by the known type takes the posts query from 87 µs to 5 µs in a micro-benchmark, which should put Rustango close to raw sqlx. Matching Go's queries also showed API gaps: no related columns in values_list (#2657), no = ANY($1) array bind (#2658) and no batch many-to-many prefetch (#2659).
Interpreted runtimes
Same rules, same day. Requests per second:
Uncached pages
Same SQL shape, same HTML, 50 connections
req/s · higher is better- Python
- Python, ASGI
- PHP · Laravel
- WordPress
Cached pages
Served from a Redis page cache
req/s · higher is better- Python
- Python, ASGI
- PHP · Laravel
- WordPress
Compute
Primes below 20 000, no database
req/s · higher is better- Python
- Python, ASGI
- PHP · Laravel
- WordPress
- Uncached pages: the best interpreted result is 6–11× behind Go and plain Rust on index and detail, and about 5× on the tag page.
- WordPress wins the cached race among interpreted runtimes. Its
advanced-cache.phpdrop-in answers from Redis before WordPress even loads, at 21 000 requests per second. Uncached, it runs 11–13 queries per page on MySQL (it doesn't support PostgreSQL). - Octane is 2–3× php-fpm on uncached pages. Keeping Laravel resident in memory still matters.
- Hypercorn vs gunicorn: the views are synchronous, so ASGI only adds a threadpool hop — Hypercorn is 2× slower on detail and 4× slower on cached pages than gunicorn running the same app.
- PHP out-computes CPython about 4× on the integer loop.
- Memory under load: 89 MiB for Laravel on php-fpm up to 241 MiB for Octane, 224 MiB for the Python app on gunicorn and 296 MiB on Hypercorn. That is 10–40× a Rust binary.
Memory under load
Resident memory of the app container, one scale for all
MiB · lower is better- Rust
- Go
- PHP · Laravel
- WordPress
- Python
- Python, ASGI
What I take from it
A fair benchmark first finds the bugs in your benchmark, then the bugs in your code.
Rust and Go are in the same league; neither wins everywhere, and the database decides more than the language on uncached pages. Rust's clearest wins are cached throughput, tail latency and memory. Rustango's job now is to close the 32% gap to raw sqlx, and the profile says most of it is one fix away.
The full tables, latencies, footprints and method are in the Rustango docs.