ORM vs stored procedures harness
ORM vs stored procedures harness
The harness behind a debunk-style post: “ORM vs stored procedure” is not one axis, it’s several confounded ones. When you hold the generated SQL constant, the gap nearly vanishes. The scary numbers people attribute to “the ORM” actually come from named, nameable issues — N+1, client-side materialization, and non-parameterized ad-hoc SQL — none of which is “ORM” as a category.
It runs a digest-pinned Postgres 16, seeds a realistic customers / orders
schema (5,000 customers, ~50,000 orders, with the fk index a sane person adds),
and for every experiment writes a PL/pgSQL function that returns exactly the same
result set as the ORM/raw path it is compared against. Each experiment isolates
one axis.
The four experiments
- A. Identical SQL (the mirage) — a parameterized single-join lookup (one customer + their most recent order), three ways: SQLAlchemy ORM, raw psycopg, and a PL/pgSQL function. Same SQL underneath. ORM adds only a small constant.
- B. N+1 — fetch M customers then their orders: naive lazy-load (1 + M real
round trips, both raw and ORM
lazyrelationship) vs an eager join (1) vs the SP (1). Measures latency and round-trip count. The naive path is the outlier; labelled as misuse, not “ORM loses”. - C. Materialization — fetch K rows: ORM full object hydration with the identity map / change tracking vs raw psycopg tuples vs SP tuples. Same SQL underneath — this isolates the client-side hydration cost. Latency + peak memory.
- D. Plan cache / parameterization — the same logical aggregate run many
times: ad-hoc string-concatenated SQL (a new query text every call) vs a
parameterized query (psycopg auto-prepares it) vs the SP. Reads
pg_stat_statementsfor entry count and parse/plan time.
Results (3 full runs, means over runs)
Postgres 16.14, SQLAlchemy 2.0.51, psycopg 3.3.4. p50 latencies in microseconds.
| Experiment | variant | p50 (µs) | note |
|---|---|---|---|
| A identical SQL | raw psycopg | 189 | 1 round trip |
| stored proc | 192 | 1 round trip | |
| ORM SQLAlchemy | 315 | 1 round trip — a ~125 µs constant, same SQL | |
| B N+1 | raw naive (1+M) | 19,379 | 101 round trips |
| ORM naive lazy (1+M) | 35,095 | 101 round trips | |
| ORM eager join | 6,012 | 1 round trip | |
| raw eager join | 704 | 1 round trip | |
| stored proc | 746 | 1 round trip | |
| C materialization | raw tuples | 2,630 | 843 KiB peak |
| SP tuples | 2,608 | 842 KiB peak | |
| ORM hydration | 9,669 | 4,444 KiB peak — objects + identity map | |
| D plan cache | ad-hoc concat | 249 | plan ~0.009–0.015 ms/call, 1 pgss entry |
| parameterized | 214 | plan ~0.0004 ms/call, 1 pgss entry | |
| stored proc | 210 | plan ~0.0002 ms/call, 1 pgss entry |
What each experiment actually shows
- A — ORM, raw, and SP are within a small constant of each other. The ORM overhead is Python-side object/statement machinery, not the database. This is the whole point of the post: with the SQL held constant there is almost nothing to argue about.
- B — the gap is enormous (naive is ~25–50× the eager/SP paths) and it is entirely the round-trip count. The eager ORM join and the SP both collapse to one round trip; the raw eager join and the SP are basically tied. N+1 is the villain, not the ORM.
- C — raw tuples ≈ SP tuples. ORM hydration is ~3.7× the latency and ~5× the memory, and that cost is 100% client-side (building mapped objects and registering them in the Session’s identity map). Same query, same rows.
- D — the honest one. Two things people say about ad-hoc SQL are tested here,
and they don’t both hold:
- “Ad-hoc SQL explodes
pg_stat_statements.” On modern Postgres, false.pg_stat_statementsnormalizes literal constants, so 500 ad-hoc queries with 500 different inlined ids collapse to one normalized entry — same as the parameterized and SP variants (all showpgss_entries = 1). - “Parameterizing / SPs cache the plan.” True, but small, and it’s parameterization doing the work, not the stored procedure. Ad-hoc pays real parse+plan on every call (~0.009–0.015 ms), while the parameterized query (auto-prepared by psycopg after 5 uses) and the SP both drop to ~0.0004 ms/call — a ~20–30× planning gap. For a query this cheap that gap is a small slice of total latency, so the p50s barely separate. The SP has no plan-caching magic the parameterized query lacks.
- “Ad-hoc SQL explodes
Reproducibility & honesty notes
- Everything reproduced in direction across all 3 runs. A, C are rock-solid
(p50 CV < 6%). B’s ordering is stable to orders of magnitude, though the SP/raw
eager p50s are a bit lumpy on a laptop (CV up to ~12%). D’s
pgss_entriesand the adhoc-vs-parameterized planning ratio are consistent every run; the absolute ad-hoc plan time is noisy (CV ~24%) because it’s sub-15 µs and competing with laptop scheduler noise. Nothing was hidden or smoothed — see theSTABILITYblock inresults/summary.txtfor the per-run CV. - These are laptop numbers demonstrating mechanism, not capacity numbers. Absolute microseconds depend on the machine, Docker’s networking layer, and loopback latency. The contrasts (identical-SQL parity, the N+1 blowup, the hydration tax, parameterization vs ad-hoc planning) are what the post rests on, and those are what stay stable.
Run it
Docker with Compose v2, plus Python 3.10+ (psycopg 3.3 dropped 3.9; tested on 3.13).
cd benchmarks/orm-vs-stored-procedures
docker compose up -d --wait # postgres on 127.0.0.1:5433
python3.13 -m venv /tmp/orm-venv && source /tmp/orm-venv/bin/activate
pip install -r requirements.txt
python benchmark.py # seeds, runs all 4 experiments 3x, writes results/
docker compose down -v # tear everything down
Everything is env-configurable: PGHOST PGPORT PGDATABASE PGUSER
PGPASSWORD, RESULTS_DIR (default results/), RUNS (default 3), and the
per-experiment iteration knobs (A_N, B_M, B_N, C_K, C_N, D_N, plus
*_WARM; see the top of benchmark.py). Warmup iterations are discarded; each
measured path reports p50/p95/p99/mean/stdev.
Results files
summary.txt— the captured console run (means over runs + the per-run stability table).exp_a_identical_sql.csv— per-run, per-variant latency percentiles for A.exp_b_n_plus_1.csv— B, including theround_tripscolumn.exp_c_materialization.csv— C, includingpeak_kib.exp_d_plan_cache.csv— D, includingpgss_entries,pgss_calls, andplan_ms_per_call.run_metadata.csv— Postgres version, image digest, library versions, iteration counts, seed sizes.attempts/— kept for any lumpy/non-reproducible shapes worth preserving (empty when the headline suite is clean).