Pin the Pool Before You Blame the ORM

August 10, 20268 min readbenchmarks, go, postgres, orm, methodology
On this page

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

  1. Pin the connection pool before you blame the library. Unpinned GORM vs warm pgxpool measures reconnect defaults, not ORMs.

  2. 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.

  3. 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.

  4. 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.

Point read (R1) p50 — RDS same-AZPools pinned · scale 0.1 · 3 reps · directional
0200400600800nop (no DB)523 µs harness floorsqlc-stdlib610 µsaa-twin615 µs noise controlpgx-stdlib617 µspgx-raw617 µs baselinesqlc-pgxpool618 µsgorm-raw624 µsgorm-builder625 µssqlx-stdlib628 µs

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-stdlib610
aa-twin615
pgx-stdlib617
pgx-raw617
sqlc-pgxpool618
gorm-raw624
gorm-builder625
sqlx-stdlib628

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.

Nested read (R6) p50 — RDS same-AZSame pool pin · same data · only the access strategy differs
05001,000nop (no DB)533 µs harness floorsqlc-stdlib675 µspgx-raw680 µs 1 statementsqlc-pgxpool681 µsaa-twin688 µspgx-stdlib688 µssqlx-stdlib689 µsgorm-raw694 µs GORM + fixed SQLgorm-builder946 µs Preload · 1.39×

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-stdlib675
pgx-raw680
sqlc-pgxpool681
aa-twin688
pgx-stdlib688
sqlx-stdlib689
gorm-raw694
gorm-builder946

Takeaway:

  • gorm-rawpgx-raw → GORM’s machinery with fixed SQL is not the villain.
  • sqlc-pgxpoolpgx-raw → codegen did not win a free latency crown once SQL matched.
  • gorm-builder is 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 loopbackRDS same-AZ. Steeper line = more network hurt.

Nested read (R6) p50 — loopback → RDSSame client · only the hop changes
LoopbackRDS same-AZpgx-raw (1 stmt) 648680 µsgorm-builder (Preload) 798946 µs

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)6486801.05×
gorm-builder (Preload)7989461.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.

Mixed API throughput — 100 concurrent workersClosed loop · laptop · scale 0.002 · directional
05,00010,00015,00020,000pgx-raw19,750 ops/ssqlx-stdlib18,569 ops/spgx-stdlib18,474 ops/sgorm-raw17,717 ops/sgorm-builder8,190 ops/s ~½ the pack

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-raw19,750
sqlx-stdlib18,569
pgx-stdlib18,474
gorm-raw17,717
gorm-builder8,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

PriorityChoiceWhy (from these charts)
1pgx/v5 + pgxpool, knobs pinned day onePool health dominates fake library gaps
2sqlc (or hand SQL) for product queriesSame speed class as raw pgx; you own the SQL
3One round trip per hot handler as a design ruleChart 2–4 are all “RTT count” stories
4GORM only with eyes openFine for CRUD if hot paths use joins/raw, not default Preload trees
5Don’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)

ControlWhat it isCloud open-loop
nopFull harness, no databasep50 ~525 µs — most of every absolute number
aa-twinpgx-raw under a second labelgap ~1–9 µs — smaller than this is not a finding
in-flightLittle’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 pieceQuestion it answersWhy 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 campaignHarden charts 1–3 at scale 1 / ≥10 repsCitation-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 QPSCapacity under real hop, not laptop mixed onlyChart 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)

KnobValue
max/min conns16 / 16
lifetime / idle24h / 24h (not zero)
lifetime jitter0
statement / description cache512 / 512
GORM PrepareStmtfalse
database/sqlprefilled (MinConns has no stdlib analogue)

Suites on AWS this pass

IDOfferedWindowSamples
Z1 ping4000/s2.5s10k
R1 point3000/s2.5s7.5k
R2 list1500/s2.5s3.75k
R6 nested800/s3.5s2.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
Repo
github.com/srkyaganti/db-lt
Runtime
local: tens of minutes at SCALE=0.1; cloud dual-tier: multi-hour if re-applied