Every few months the same thread comes back around: someone benchmarks their ORM against a hand-written stored procedure, the stored procedure wins, and the comment section splits into the people who always knew ORMs were slow and the people who say you held it wrong. I’ve been on both sides of that thread. So I finally sat down to settle it with numbers, held the database constant, held the schema constant, held the data constant, and ran the same query three ways: through SQLAlchemy’s ORM, through raw parameterized SQL, and through a PL/pgSQL function standing in for the stored procedure.
The benchmark did not settle it. It kept refusing to. Every time I thought I had one clean comparison, it turned out I was measuring two things at once, and when I pulled them apart the ORM-vs-stored-procedure part of the gap was almost always the small part. The big numbers people quote are real, they’re just not measuring what the title says.
The problem
“ORM vs stored procedure” sounds like one axis, fast on one end and slow on the other. It isn’t. It’s at least four things wearing one label: what SQL actually gets sent, how many round trips it takes to send it, how much work the client does turning rows back into objects, and whether the query is parameterized. A stored procedure quietly bundles good answers to all four. An ORM lets you pick a bad answer to any of them, and then the benchmark blames the ORM. If you want to know what an ORM actually costs, you have to hold the other three still, one at a time, and most benchmarks don’t.
Everything below ran against Postgres 16.14 in Docker, 5,000 customers and 49,942 orders, SQLAlchemy 2.0.51 on psycopg 3.3.4. Every number is a p50 over 1,000 calls, averaged across three full end-to-end runs. These are laptop numbers, they tell you the shape of the difference, not your production capacity.
When the SQL is the same, so is the speed
Start with the fairest fight I could build. One parameterized lookup, a customer joined to one of their orders, returning the identical rows three ways. The ORM query, a raw psycopg query with the same SQL, and a call to a PL/pgSQL function whose body is that same SQL. Same plan, same execution, same bytes on the wire. The only thing that varies is the layer sitting between me and the socket.
Identical single-row query, three ways (p50, microseconds)
That’s the number the whole post hangs on. When you actually hold the SQL identical, the stored procedure and the raw query are the same thing, 189 versus 192 microseconds, and the ORM is slower by a fixed 125 microseconds it spends building a query object, mapping the result, and bookkeeping. On a single row that’s a 1.6x ratio and it looks bad in a bar chart. But it’s 125 microseconds. It does not grow with your data, it does not grow with your load, it’s the toll you pay once per query for not writing the SQL yourself. If your handler does anything else at all, a template render, a second query, a network hop to some other service, that 125 microseconds disappears into the noise.
So the honest version of the fairest fight is: there is barely a fight. Which means every dramatic benchmark you’ve seen was measuring one of the next three things instead.
The N+1 blowup that gets blamed on the ORM
Here’s the one everybody’s actually seen. Load 100 customers, then load each customer’s orders. The naive way, the way an ORM invites you into if you touch a lazy relationship in a loop, is one query for the customers and then one more query per customer. 101 round trips. The stored procedure does it in one.
Load 100 customers and their orders (p50, microseconds)
There’s your viral benchmark. 35 milliseconds for the ORM, 746 microseconds for the stored procedure, call it 47x and post it. But look at the other three bars before you do. Raw psycopg written the same naive way, a loop firing one query per customer, is 19 milliseconds, half the ORM but still catastrophic, and it’s not using an ORM at all. The thing killing both of them is 101 round trips, not the object mapper. And the moment you tell the ORM to do the join it was always capable of, it drops to 6 milliseconds, one round trip, in the same league as the hand-written query.
The ORM’s real sin here isn’t slowness, it’s that it will happily let you write the slow version and it looks like ordinary code. for customer in customers: customer.orders is one line and it’s 101 queries. Nothing warns you. That’s a genuine, fair criticism of ORMs, they make the expensive thing look cheap. But it’s a criticism of the abstraction hiding the round trips, not of the ORM being slow at what it does. Written correctly, the ORM is 6 ms, and the gap to the stored procedure is round trips you chose to make, not a tax the ORM charged you.
The part that’s actually the ORM: turning rows into objects
Now the fight the ORM genuinely loses, and it’s worth being honest about it because it’s the one people usually skip. Fetch 3,000 rows and hand them back. Raw psycopg gives you tuples. The stored procedure gives you tuples. The ORM gives you 3,000 fully hydrated, change-tracked, identity-mapped objects, and that costs real money.
Latency, 3,000 rows (p50 µs)
Peak memory (KiB)
This one’s fair and the ORM owns it. 2.6 milliseconds to pull tuples, 9.7 milliseconds to turn those same tuples into mapped objects, and five times the memory to hold them. The stored procedure isn’t winning here because it’s a stored procedure, it’s winning because it hands back tuples and stops. Point raw psycopg at the same rows and it ties the stored procedure exactly, 2,630 versus 2,608 microseconds. The delta is entirely the object graph, the identity map, the change tracking that lets you mutate an object and have the ORM figure out the UPDATE. You’re paying for a feature. If you’re reading 3,000 rows to serialize them straight to JSON, you’re paying for a feature you’re about to throw away, and SQLAlchemy will let you skip it, that’s what the Core API and .execution_options are for. But by default, hydration is the one place the ORM is genuinely, measurably heavier, and no amount of holding it right makes it free.
The plan cache advantage that Postgres already gave everyone
The last one is the argument I was most sure I’d confirm, and it’s the one that surprised me. The folklore says stored procedures win because their plans get cached, while ORMs generate ad-hoc SQL that reparses and replans every single time and thrashes the plan cache. So I ran the same logical query 500 times three ways: ad-hoc SQL with the literal values concatenated into the string, a parameterized version, and the stored procedure, and I watched pg_stat_statements to count how many distinct statements each one produced.
Distinct pg_stat_statements entries after 500 calls
Parse + plan time per call (ms)
The cache-thrashing story is just false on a modern Postgres. Ad-hoc concatenation with 500 different literal values produced one entry in pg_stat_statements, not 500, because Postgres normalizes literals out of the statement fingerprint before it records them. The plan cache doesn’t explode. What is true, and I don’t want to wave it away, is that ad-hoc SQL does pay to parse and plan every call, and parameterized queries and stored procedures don’t, that’s the 0.011 ms versus 0.0004 ms in the right panel, a real 20-to-30x ratio. But look at the absolute number. It’s eleven microseconds, on a query that takes 210. End to end all three finished within 40 microseconds of each other. The parameterization advantage is real and it is almost never the thing you can feel.
And here’s the part that matters for the ORM argument: this axis isn’t about ORMs at all. ORMs parameterize. That’s the default, it’s how they defend against SQL injection. The slow bar in that chart, the ad-hoc string concatenation, is the thing you get when you skip the ORM and hand-build SQL with an f-string. The stored procedure’s plan-caching edge is really just parameterization, and the ORM already gives you parameterization for free.
The takeaway
I set out to benchmark ORM against stored procedures and I couldn’t, because it isn’t one comparison. Once you hold the database still and pull the confounds apart, here’s what’s actually left:
- Same SQL, same speed. A parameterized ORM query and a stored procedure running identical SQL are 125 microseconds apart, a fixed client-side constant, not a multiplier. On anything but a tight loop it’s invisible.
- N+1 is round trips, not ORMs. The 47x blowup is 101 round trips against 1, and raw hand-written SQL does it just as badly. The fair complaint is that ORMs make the round trips easy to not see. Written as a join, the ORM is back in the pack.
- Hydration is the one real ORM tax. Turning 3,000 rows into tracked objects costs ~3.7x the time and 5x the memory over raw tuples, and it’s the only place the ORM is genuinely heavier. Skip it when you don’t need the objects.
- The plan-cache argument is mostly folklore. Postgres normalizes literals, so ad-hoc SQL doesn’t thrash
pg_stat_statements. It pays real parse-and-plan cost, about 3 microseconds a call, and the ORM sidesteps it anyway by parameterizing.
So if you’re reaching for a stored procedure because you read that ORMs are slow, you’re solving the wrong problem three times out of four. Fix your round trips, skip hydration when you’re just serializing, and let the ORM parameterize the way it already wants to. The one time a stored procedure genuinely wins on speed is when you’re moving a lot of rows through heavy set-based logic and you want to do it inside the database without hydrating anything, and even then, raw SQL from your app gets you the same win. The one thing to remember: before you benchmark two things, make sure you’re only comparing two things. I wasn’t, and neither is most of the internet.
The harness, all four experiments and the raw CSVs, is on GitHub. Numbers are from my laptop, so read them as ratios and shapes, not as your capacity.
Comments