ORM vs SQL in real projects: I stopped picking a side — here’s what I use when
James Okonkwo
September 18, 2026
I used to have a personality about this. ORM people were unserious. SQL people were gatekeeping. I have been both, loudly, in the same Slack workspace, six months apart.
The projects that actually shipped did not care about my personality. They cared whether the next engineer could change a field without inventing a Cartesian product, and whether the query plan I had not read would survive Black Friday. I stopped picking a side. I pick a layer, per problem, and I write down why.
This is the mix I use in 2026: Hibernate or Prisma when the unit of work is a graph I will edit, SQL (often through jOOQ, sqlc, or a plain file) when the unit of work is a result set I will measure, and a hard rule about not letting the first one pretend to be the second.
What an ORM is actually good at
An ORM is a unit-of-work and identity map with a query builder taped on. The taped-on part is what people fight about. The unit of work is why I still start there on a write-heavy domain.
If I am editing an Order that has Lines, taxes, and an Allocation, I want to load a consistent graph, mutate it in the language I am paid to think in, and flush. Hibernate, Entity Framework, SQLAlchemy’s session, Prisma’s interactive transactions — they all earn their keep on that path. The alternative is a pile of UPDATE statements I will get slightly wrong when someone adds a status column.
Change tracking is the feature I did not appreciate until I wrote the raw version. Dirty checking, optimistic locks on a version column, cascading a delete that I actually meant. When that is the job, I do not want to be a human ORM.
Schema migrations are not an ORM feature, but the good ecosystems bundle them. Prisma Migrate, Flyway plus Hibernate validation, Alembic. I want the model and the migration in the same pull request. That is independent of whether the read path is raw SQL.
What I do not want from the ORM: reporting, search, anything with a window function, anything I will explain to a DBA with a straight face. If I am about to write a fetch join that needs a paragraph of comments, I have already lost. The comment is a white flag.
The SQL I actually write
I write SQL when the question is shaped like a question, not like an object. “Give me last week’s failed payouts grouped by merchant, with the previous week as a comparison, excluding test accounts.” That is a query. Forcing it through entities is how you get an N+1 that you then “fix” with a join that triples memory and still sorts in the app.
My defaults in 2026:
- Postgres, almost always. If the company is on SQL Server or Oracle I write that dialect and I do not pretend it is Postgres.
- sqlc in Go. Types from the query. I trust it more than a generic query builder when the SQL is the product.
- jOOQ in Java when the team will not accept string files. It is SQL with a compiler, not an identity map. Different tool.
- A
queries/directory of.sqlfiles with a thin wrapper when the team is small and the ORM is Prisma. Prisma.sql is fine. So is a tagged template and a review culture that reads EXPLAIN.
I still parameterize. I still want the query in version control, not assembled from twelve boolean flags in a service class. Dynamic SQL is allowed. Dynamic SQL that nobody can EXPLAIN in staging is how I end up on a Sev2 named after me.

The line I use on a real service
Last year I owned a Kotlin service that settled marketplace payouts. The write path was Hibernate. PayoutBatch, PayoutLine, LedgerEntry. Optimistic lock on the batch. A domain method that applied a failed-line reason. That graph is why we did not bounce money twice. I would not rewrite it as six stored procedures to make a conference talk.
The read path that powered the finance CSV and the Datadog-facing “how late are we” dashboard was not Hibernate. It was two SQL files. One had a window function. One had a FILTER clause and a join to a calendar table. When a finance person asked for an extra column, we changed the SQL and a DTO. We did not add a @Formula. We did not teach Hibernate a new entity that existed only for a report.
That split is the whole essay. Writes and invariants: ORM. Questions and aggregates: SQL. Shared tables. Same migrations. Different access libraries. The fear is always “two ways to talk to the database.” The reality of one way is worse — you get an ORM that is also a reporting engine, or raw SQL that is also your concurrency control.
Prisma users hit the same line earlier because Prisma is honest about what it is. Great at typed CRUD. Awkward the moment you want a lateral join. I stop apologizing for $queryRaw on that day. I do not stop using Prisma for the admin mutations.
Performance is usually a modeling problem
When someone says “the ORM is slow,” I ask for the query log. Nine times out of ten I find one of these:
- A lazy association touched in a loop. The N+1. Real, boring, fixable with a join fetch, a batch size, or by not using the graph for that screen.
- A select-star entity loaded to read two columns, a thousand times.
- A pagination that does
COUNT(*)over the same join that produces the page, on a table that needed a partial index instead of a new ORM setting. - A transaction held open while the service calls Stripe. The ORM cannot save you from a unit of work that includes the network.
Sometimes the ORM is the problem. Hibernate’s flush order has surprised me. Prisma’s implicit return of the whole record after an update has surprised me on a hot path. Then I drop that path to SQL and I leave the rest alone. Rewriting the entire data layer because one endpoint was dumb is how you get a second dumb data layer and a migration you will not finish.
I profile in production-shaped data. A local Docker Postgres with 40 rows will praise every plan. I keep an anonymized snapshot that is fat enough to lie less. If you do not have that, your ORM-versus-SQL opinion is a vibe.

Transactions, tests, and the stuff people skip
I test the write path with the ORM against a real Postgres in CI — Testcontainers, a compose service, I do not care, as long as it is not H2 pretending to be a grown-up. I test the SQL files against the same database with the same migrations. If the SQL test is “we mocked the repository,” you are testing your typing, not the query.
Isolation is part of the choice. A repeatable-read transaction around an ORM graph is a different animal than a single INSERT … ON CONFLICT statement. I pick the mechanism that matches the invariant. “Don’t double-pay this invoice” is often a unique constraint and an upsert, not a carefully ordered entity flush. When the invariant is a unique constraint, SQL (or a migration) is the source of truth. The ORM just has to not violate it.
I have started putting the invariant in the database even when the happy path is ORM. Check constraints. Partial unique indexes. A trigger I hate but can name. If the only copy of the rule is a service method, a second writer — a script, a backfill, a future Go worker — will break it. This is not anti-ORM. This is anti-wishful-thinking.
When I would go SQL-only
Small Go services with sqlc. Analytics-adjacent workers. Anything that is a pipeline, not a domain model. A team that already thinks in relations and does not want to learn a session lifecycle.
I would not go SQL-only on a 200-entity insurance domain with twelve kinds of document and a lot of “save this form.” You will reinvent dirty checking poorly. I have seen the poorly. It was a folder of stored procedures and a Java layer that mapped ResultSets by column index. The people who wrote it believed they had escaped ORM overhead. They had built an ORM without an identity map, which is the overhead without the feature.
When I would go ORM-only
A CRUD admin, a short-lived product, a team that will not read EXPLAIN and will not grow a reporting habit. Prisma plus a couple of indexes will get you far. I still want an escape hatch — raw query, or a replica and a SQL file — before the first dashboard request lands on the primary.
I would not stay ORM-only if a data scientist is about to live in the same tables. They will write SQL anyway. Meet them with views or a documented replica. Do not make them call your service for a group-by.
The decision card I keep
I write this in the README of the data module, not in a style guide nobody opens:
- Mutating a known aggregate: ORM, explicit transaction, version column.
- List/detail for the same aggregate: ORM or a small query object. If the list needs four joins for display-only fields, it is not the aggregate. It is a query.
- Any number a finance or ops person will argue about: SQL, tested, EXPLAIN saved next to the file.
- Search: not the ORM, not LIKE ‘%foo%’. Postgres FTS, or Meilisearch, or whatever we already operate. Different problem.
- Cross-aggregate consistency: database constraint first, then a transaction, then an outbox. Never “we will remember to update both entities.”
Sides are for Twitter. Projects have paths. I start with an ORM on the write path because I have better things to do than reimplement a session. I drop to SQL the first time the question is a question. I stay there for that path. I do not convert the rest of the service as a purity exercise.
If you need a slogan: objects for writes, relations for questions. If you need a process: log the queries on the slow endpoint before you rewrite the stack. Most of the time the stack is fine. The model is lying about what the page is asking.