Skip to main content
The Hidden Cost of an ORM — Anselm Fowel
AI & Technology

The Hidden Cost of an ORM

9 min read
313 views
Share:

A few years ago I spent the better part of a Friday night staring at a payment reconciliation job that had gone from taking ninety seconds to taking forty minutes. Nothing in the code had changed. The data volume had grown, sure, but not by twenty-six times. When I finally turned on query logging, the job was firing 180,000 database round-trips to process about 6,000 settlements. The ORM had been quietly translating one innocent-looking loop into a small denial-of-service attack against our own primary.

That night is why I get twitchy when a team tells me the data layer is "handled" because they're using an ORM. Object-relational mappers are genuinely useful tools. They are also one of the most reliable ways I know to hide operational cost behind pleasant-looking code, and in a regulated payments business that cost lands on you at exactly the wrong moment.

The abstraction that lies a little

An ORM sells you a comforting story: your database is just a collection of objects, and you can work with them the way you work with any other object in memory. Load a customer, walk its list of transactions, tweak a field, save. The trouble is that this story is false in one specific way that matters enormously. A field access on an in-memory object costs nanoseconds. The same field access, when it triggers a lazy load, costs a network round-trip and a query plan lookup. The syntax is identical. The cost differs by six orders of magnitude.

Good abstractions hide complexity you don't need to think about. This one hides complexity you absolutely do. That's the distinction I care about, and it's why I've never been able to treat an ORM as a set-and-forget layer. The mapping is fine. The pretending-the-network-doesn't-exist part is where the money leaks out.

The N+1 tax nobody budgeted for

The classic failure is the N+1 query, and my Friday-night disaster was a textbook case. You load a list of parent records, then loop over them touching a child collection, and each iteration fires its own query. One query becomes N+1. In a demo with ten rows you'll never notice. In production with 6,000 rows and a lazily-loaded navigation property, you get the forty-minute job.

Here's the shape of the code that did it, cleaned up so I don't have to relive the original:

// Looks innocent. Fires one query per settlement.
var settlements = await db.Settlements
    .Where(s => s.BatchDate == today)
    .ToListAsync();

foreach (var s in settlements)
{
    // Lazy load: each access hits the database again
    decimal fees = s.LineItems.Sum(li => li.FeeAmount);
    Reconcile(s, fees);
}

The fix is a one-line change to eager-load the children, and it took the job from forty minutes back to under two:

var settlements = await db.Settlements
    .Where(s => s.BatchDate == today)
    .Include(s => s.LineItems)   // one join, not 6,000 queries
    .ToListAsync();

The maddening part is that the broken version is the one that reads more naturally. Nobody writes a lazy-load bug on purpose. You write ordinary object-oriented code, and the ORM turns it into something that would get a junior developer fired if they'd written the SQL by hand.

Selecting everything, always

The other silent tax is over-fetching. Most ORMs, by default, hydrate the full entity. You wanted the customer's name and status for a dropdown; you got all 40 columns, including the two large JSON blobs and the base64 signature image. Multiply that across a paginated list and you've turned a 4KB response into a 4MB one, plus the memory pressure of materializing objects you'll throw away in the next line.

I push teams hard toward projecting into purpose-built DTOs rather than passing entities around. Select exactly the columns the screen needs, no more. It's a little more typing and it feels less elegant, and I do not care, because the version that hydrates the whole aggregate is the version that pages someone at 3am when a report query starts spilling to disk. A projection is also honest about intent in a way a bare entity load never is.

The query plans you can't see

When you write SQL by hand, you own the query, which means you can read it, run it through an execution plan, and see when the optimizer picks a scan over a seek. When the ORM generates it, that query is often invisible until you go looking for it, and the generated SQL can be genuinely strange. I've seen ORMs emit correlated subqueries where a join would do, translate a simple filter into a five-way nested SELECT, and produce parameter sniffing problems because the same LINQ expression compiled to a plan tuned for a wildly different input.

The teams that stay out of trouble treat generated SQL as something to be audited, not trusted. That means:

  • Turning on query logging in staging and actually reading what comes out, not just checking that the tests pass.
  • Capturing the slowest generated queries and running them through the real execution plan against production-shaped data.
  • Keeping a hard rule that any query on a hot path either has a reviewed plan or gets rewritten as raw SQL.
  • Watching parameter types, because an ORM that sends an nvarchar parameter against a varchar column will cheerfully defeat your index and never tell you.

That last one cost a team I advised about three weeks of "the database is slow" before anyone noticed every query was doing an implicit conversion that killed index usage. The ORM did exactly what it was told. Nobody had told it the right thing.

Enjoying this article?

Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.

Migrations that bite in production

Code-first migrations are a genuine convenience and also a foot-gun with a hair trigger. The tool that generates a migration from your model changes does not know that dropping a column locks the table, or that adding a non-nullable column with a default rewrites every row, or that renaming a property will be interpreted as drop-and-recreate and quietly delete production data. In a low-traffic app you get away with it. In a payments ledger with a strict change-control process and an auditor who reads the migration scripts, you do not.

The ORM will happily generate a migration that passes every test and destroys three months of transaction history the first time it runs against real data. The test database was empty. Production was not.

My rule after getting burned: every generated migration gets read by a human, line by line, before it goes anywhere near an environment that matters. Destructive operations get flagged and staged separately. And for anything touching a financial table, I want the raw SQL checked into version control, not a serialized model snapshot that only the framework can interpret. When the regulator asks what changed and when, "the ORM handled it" is not an answer.

Where does the transaction actually begin

ORMs blur transaction boundaries in a way that's fine until it isn't. The unit-of-work pattern batches your changes and flushes them on save, which is convenient, but it also means the moment your transaction opens and the isolation level it runs at are decisions the framework is making on your behalf. In a payments system those are not decisions I'm comfortable delegating.

I've watched a team ship a "harmless" refactor that moved a save inside a loop, turning one transaction into 5,000 short ones and destroying the atomicity a downstream reconciliation depended on. Nothing threw an error. The data was just subtly, permanently wrong in about one out of every few thousand runs. When money is involved, I want the transaction boundary to be explicit and obvious in the code, not an emergent property of when the ORM decided to flush. If you can't point at the exact lines where a transaction opens and commits, you don't control your consistency guarantees, and you will find that out during an incident.

When the ORM genuinely earns its keep

I want to be fair, because I do reach for these tools. For the boring 80 percent of an application — the CRUD screens, the admin panels, the internal tooling, the settings pages — an ORM is the right call and hand-rolled SQL would be a waste of everyone's time. The productivity is real. The change-tracking is real. The protection against a whole class of SQL injection bugs is real and not to be sniffed at in a regulated shop.

My actual position is a split. Use the ORM for the mundane paths where developer speed matters and load is trivial. Drop to hand-written SQL, or a thin micro-ORM like Dapper, for the hot paths, the reporting queries, and anything that touches the ledger. Dapper in particular is the compromise I keep coming back to: you write the SQL, so you own the query and the plan, but you still get clean object mapping without hand-writing a reader for every column. It's the ninety percent of the benefit with about ten percent of the surprises.

What I actually tell my teams

The mistake is treating "use an ORM" as an architecture decision made once, at the start, for the whole system. It isn't. It's a per-query decision, and the healthiest codebases I've run let both styles live side by side without anyone getting religious about it. The ORM is a default, not a law.

So the guidance is simple. Assume the ORM is generating something dumber than you'd write by hand, and go check. Log the queries. Read the plans on anything that runs more than a few times a second. Project instead of fetching whole aggregates. Keep transaction boundaries explicit. Read every migration before it runs. And the day a query shows up in your slow-query log, don't reach for another eager-load hint reflexively. Ask whether this is a path that should have been raw SQL from the start.

Anselm Fowel, CTO and fintech architect
Anselm Fowel — CTO & fintech architect

Conclusion

The real cost of an ORM isn't the framework overhead, which is usually negligible, and it isn't the learning curve. It's that the tool makes expensive operations look cheap, so the people writing the code stop feeling the weight of what they're asking the database to do. That lost sense of cost is the thing to watch for. The best data-layer engineers I've worked with can still read the SQL their ORM generates and wince at the ugly ones, and that wince is worth more than any amount of clever configuration. Keep the tool. Just never let it convince you the database went away.

Enjoyed this article? Share it with others!

Share:

Get new posts in your inbox

Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.

Comments (0)

Leave a Comment

Comments are moderated and will appear after review.

No comments yet. Be the first to comment!

About the author

Anselm Fowel

Anselm Fowel

Chief Technology Officer & fintech architect. 16+ years leading engineering across AlliancePay, Mondu, Transalliance, Global Accelerex, and Fidelity Bank — writing here about engineering leadership, fintech architecture, and AI in production.

Read next