Pin the Pool Before You Blame the ORM
Two months ago I promised a sqlc-versus-GORM teardown. The private harness was broken. I rebuilt it in public as db-lt, ran a first AWS pass against RDS in the same AZ and loopback on the same box, and stopped.
The charts below are from that pass. They are directional (scale 0.1, 3 reps, dirty tree — not the publishable bar). Absolute µs are dominated by a ~0.5 ms harness floor. Read shapes and gaps, not “Postgres is X microseconds.”
What to remember
~tie
Simple reads on RDS
9 libraries, ~18 µs spread
1.39×
Preload nested read
vs one-statement path
~½
Throughput under load
chatty vs one-shot SQL
sqlc ≈ pgx
When SQL matches
codegen ≠ free speedup
The rules
-
Pin the connection pool before you blame the library. Unpinned GORM vs warm
pgxpoolmeasures reconnect defaults, not ORMs. -
On a real same-AZ network, simple point reads do not care which Go library you picked. The whole pack sits in one band; the network + harness own the floor.
-
The cost that survives is round-trip count, not “ORM.” GORM with hand-written SQL tracks raw pgx. GORM with Preload pays multi-statement tax — and the network amplifies that tax.
-
For a new project:
sqlc+pgxpool+ pinned pool + non-chatty SQL. Switch to an ORM only if you accept discipline on hot paths. Do not expect sqlc to cut your RDS bill by itself.
Chart 1 — Point read on RDS: everyone is the same bar
One round trip, one narrow row. Offered 3000 ops/s, pools pinned identical on every variant. Lower is better.
Non-nop spread is ~18 µs around ~620 µs. A/A noise is ~2 µs. If your service talks to Postgres over a real AZ hop, this is why ‘which driver is faster?’ is usually the wrong argument.db-lt aws-tierC · publishable: false
Data
| Value (µs) | |
|---|---|
| nop (no DB) | 523 |
| sqlc-stdlib | 610 |
| aa-twin | 615 |
| pgx-stdlib | 617 |
| pgx-raw | 617 |
| sqlc-pgxpool | 618 |
| gorm-raw | 624 |
| gorm-builder | 625 |
| sqlx-stdlib | 628 |
Takeaway: Buying sqlc, rewriting off GORM, or hand-tuning scans will not move a typical RDS point-get by a meaningful amount when the SQL is already one statement and the pool is healthy. Fix the query plan and the RTT first.
Chart 2 — Nested read: one outlier, and it isn’t “GORM”
Order + customer + line items. Hand-written / sqlc paths use one LATERAL (or
equivalent) statement. gorm-builder uses Preload — a constant handful of
statements (batched IN), not classic N+1, but still multiple round trips.
Everything from sqlc through gorm-raw sits in a ~20 µs band. Only the Preload path leaves it. ‘ORMs are slow’ is the wrong sentence; ‘chatty association loading is slow’ is the right one.db-lt aws-tierC · publishable: false
Data
| Value (µs) | |
|---|---|
| nop (no DB) | 533 |
| sqlc-stdlib | 675 |
| pgx-raw | 680 |
| sqlc-pgxpool | 681 |
| aa-twin | 688 |
| pgx-stdlib | 688 |
| sqlx-stdlib | 689 |
| gorm-raw | 694 |
| gorm-builder | 946 |
Takeaway:
gorm-raw≈pgx-raw→ GORM’s machinery with fixed SQL is not the villain.sqlc-pgxpool≈pgx-raw→ codegen did not win a free latency crown once SQL matched.gorm-builderis the tax → pay it only when the productivity is worth ~40% more p50 on this shape (and worse under load — chart 4).
Chart 3 — Network does not hide multi-statement cost — it multiplies it
Same binary, same seed, same pool. Nested read p50 on loopback → RDS same-AZ. Steeper line = more network hurt.
One-statement path barely moves (+32 µs). Preload climbs harder (+148 µs). The gap between them grows from ~150 µs to ~266 µs — each extra statement pays the RTT again. Opposite of ‘network swallows ORM cost’ on a short same-AZ hop.db-lt aws-tierL + aws-tierC · publishable: false
Data
| Loopback (µs) | RDS same-AZ (µs) | Ratio | |
|---|---|---|---|
| pgx-raw (1 stmt) | 648 | 680 | 1.05× |
| gorm-builder (Preload) | 798 | 946 | 1.19× |
Takeaway: For single-statement work, network makes libraries look identical. For multi-statement work, a real hop makes the bad pattern worse, not quieter. Laptop-only ORM benches understate production pain for chatty loaders and overstate library-CPU drama for simple reads.
Chart 4 — Under concurrency, chatty SQL halves throughput
Closed loop on a laptop (not the cloud pass): JSON in → DB → JSON out, 90% reads. Workers stay busy; this is the capacity story open-loop latency cannot tell.
One-statement paths cluster ~18–20k ops/s. Preload path ~8k. At 500 workers the same split holds (~21k vs ~8k). Pool is 16 connections either way — extra RTTs burn the pool.db-lt mixed-concurrency · publishable: false
Data
| Value (ops/s) | |
|---|---|
| pgx-raw | 19,750 |
| sqlx-stdlib | 18,569 |
| pgx-stdlib | 18,474 |
| gorm-raw | 17,717 |
| gorm-builder | 8,190 |
Takeaway: If you care about capacity and cost per core, fix chatty graphs before you rewrite frameworks. Throughput is where “better tooling” shows up — and the tooling that matters is SQL shape, not the logo on the import.
What I’d start a new project with
| Priority | Choice | Why (from these charts) |
|---|---|---|
| 1 | pgx/v5 + pgxpool, knobs pinned day one | Pool health dominates fake library gaps |
| 2 | sqlc (or hand SQL) for product queries | Same speed class as raw pgx; you own the SQL |
| 3 | One round trip per hot handler as a design rule | Chart 2–4 are all “RTT count” stories |
| 4 | GORM only with eyes open | Fine for CRUD if hot paths use joins/raw, not default Preload trees |
| 5 | Don’t expect library hop to cut RDS $ | Chart 1: you’re network-bound on simple reads |
Pin at least: max/min conns equal, lifetime/idle not left at “zero means magic default,”
and prefill database/sql pools (idle cap ≠ eager open).
Why this post exists at all
I had announced findings from a private harness that swapped allocs with bytes, raced a shared RNG, left pools at library defaults, and never wrote a GORM result file. That tree is a defect catalogue, not a citation. The public rebuild is db-lt.
One walk-back worth saying once: the old teaser called Preload “N+1.” Wrong as stated. Preload is a fixed number of batched statements per association — still expensive multi-RTT, not one query per row. Charts above measure that real cost under the right name.
How to read the instrument (30 seconds)
| Control | What it is | Cloud open-loop |
|---|---|---|
nop | Full harness, no database | p50 ~525 µs — most of every absolute number |
aa-twin | pgx-raw under a second label | gap ~1–9 µs — smaller than this is not a finding |
| in-flight | Little’s law concurrency | < 3 on AWS open-loop — we did not saturate RDS |
So: differentials and shapes, not absolute “Postgres service time.” Memory, GC, and max QPS kneepoints were not the lead metrics of this pass; capacity shows up in chart 4’s closed loop, not in the open-loop cloud tables.
What’s next in this series (database access)
This post is part 1 of a small database series: wall-clock latency, RTT, and pool
discipline. The harness and the public dataset live in
db-lt. The backlog for what the instrument should
measure next is tracked in-repo as
docs/NEXT.md — not as a
vague “more coming soon,” but as named metrics with use cases they would change.
| Planned piece | Question it answers | Why it matters |
|---|---|---|
| Memory pressure (allocs/op, bytes/op, steady heap, RSS) | Who is expensive to pack per pod? | Small limits, high replica counts, batch/ETL decode paths |
GC (pause totals, gctrace on a separate instrumented pass, optional GOMEMLIMIT cell) | Who hurts tails under heap pressure? | p99 SLOs when the app is alloc-heavy, not network-bound |
| CPU / op (rusage, core·s per 1M ops, flamegraphs on R6) | Who shrinks the instance size when RTT is tiny? | Co-located Postgres, loopback, edge boxes; cost claims that latency charts cannot support |
| Publishable dual-tier campaign | Harden charts 1–3 at scale 1 / ≥10 reps | Citation-grade numbers, clean tree, full windows |
Unpinned ladder (ladder/v0) | How much of “ORM is 3× slower” was pool defaults? | The confound this post argues for but has not quantified yet |
| RDS saturation / max QPS | Capacity under real hop, not laptop mixed only | Chart 4 on Tier C; connection-bound services |
Use-case rule of thumb for the series:
- If RTT ≫ library CPU (typical same-AZ API) → this post’s charts: pin the pool, count round trips, don’t rewrite frameworks for µs.
- If RTT → 0 or you are packing max RPS per core / tight memory → wait for (or demand) the memory/GC/CPU pass before claiming “sqlc cuts our AWS bill.”
Nothing above is pre-announced as a finding — only as a measurement plan. The last time I pre-announced a pattern without the harness, I had to walk it back.
Methods appendix (for people who will audit)
Folded so the charts stay the product. Full fingerprints live in the data files.
Topology, hardware intent, pool pin, suites, capture gaps
Topology
Two tiers, one client binary:
- Tier L — Postgres 17.10 in Docker on the client (loopback)
- Tier C — RDS Postgres 17.10, same AZ, private SG, no Multi-AZ, no proxy
Design (Terraform defaults; not all re-echoed into env.json): client r7i.4xlarge
(SMT off), RDS db.m7i.2xlarge, gp3 400 GiB / 12k IOPS / 500 MiB/s, region
us-east-1 / AZ us-east-1a, profile personal. Session ~40 min, ~$1.20; 24
resources destroyed afterward.
Recorded process fingerprint (both tiers)
Go 1.26.2, linux/amd64, GOMAXPROCS=4, scale 0.1 (14.5M rows), seed 42,
3 reps, stmt_mode=cache_statement, pool 16/16, load workers 64.
Libraries (build info): pgx v5.10.0, sqlx v1.4.0, GORM v1.31.2.
Pool pin (identical every variant)
| Knob | Value |
|---|---|
| max/min conns | 16 / 16 |
| lifetime / idle | 24h / 24h (not zero) |
| lifetime jitter | 0 |
| statement / description cache | 512 / 512 |
| GORM PrepareStmt | false |
| database/sql | prefilled (MinConns has no stdlib analogue) |
Suites on AWS this pass
| ID | Offered | Window | Samples |
|---|---|---|---|
| Z1 ping | 4000/s | 2.5s | 10k |
| R1 point | 3000/s | 2.5s | 7.5k |
| R2 list | 1500/s | 2.5s | 3.75k |
| R6 nested | 800/s | 3.5s | 2.8k |
Writes dropped (cleanup seq-scan at this scale) — not faked. Variants interleaved per rep; deltas on means. Wire protocol is pgx only for everyone.
Capture gaps
Empty git_commit, dirty tree, empty postgres_version string, no IMDS instance type
in env.json, no GC/heap series, no publishable scale-1 run yet.
Full numeric tables (if you want to recompute)
R6 RDS p50 (µs): sqlc-stdlib 675 · pgx-raw 680 · sqlc-pgxpool 681 · aa-twin 688 · pgx-stdlib 688 · sqlx 689 · gorm-raw 694 · gorm-builder 946 · nop 533
R1 RDS p50 (µs): pack 610–628 · nop 523
R6 builder extra vs pgx-raw: loopback +150 · RDS +266
Mixed 100w ops/s: pgx-raw ~19.8k · gorm-raw ~17.7k · gorm-builder ~8.2k
Raw arrays: results/aws-tierC|L in the repo; extracted medians in
public/data/db-lt/summary.json.
I pre-announced a winner. The instrument said something more useful: pin the pool, count the round trips, stop treating library logos as performance. The full campaign (scale 1, ≥10 reps, clean tree) will either harden that or replace it — either outcome beats the teaser.
Reproduce this
make up && make migrate && make seed SCALE=0.1 && make verify && make bench SCALE=0.1 REPS=3 && make report