Back to blog

Your ORM Isn't Lying to You. It's Just Not Telling You Everything.

Sep 3, 2026
13 min read
JJS
Written by Jatin Jain Saraf · Senior Software Engineer

An ORM is a translator sitting in a business meeting. It's fluent, it's fast, and after a while you stop paying attention to the original language entirely. You trust it. Most days that trust is fine. Then one sentence gets mistranslated, the deal falls apart, and you realize you never actually learned the language yourself. You just learned to rely on someone who did.

That's what Prisma, Drizzle, TypeORM, and Sequelize do to your relationship with Postgres. They don't lie. They just don't tell you everything, and the gaps are exactly where production incidents live.

This post is not "ORMs are bad, use raw SQL." Most apps should use one. It's a map of the specific places where the ORM's model of your database and Postgres's actual behavior diverge, so you know where to look when something is slow, wrong, or gone.

Why teams reach for an ORM in the first place

Before the shortcomings, the honest case for using one:

  • Types generated from your schema. Rename a column, your build breaks at compile time instead of at 2am in production.
  • Migrations as code. Schema changes are version-controlled, reviewable in a pull request, and reproducible across environments.
  • Boilerplate CRUD disappears. Most application code touching the database is SELECT, INSERT, UPDATE, DELETE on one or two tables. An ORM turns that into a few lines instead of a hand-written query and a row-mapping function, every time.

None of that is wrong. The problem is what it costs you when you stop thinking about the SQL underneath.

The N+1 query, hiding in plain sight

This is the failure mode that catches the most teams, because the ORM makes it look identical to the correct version.

javascript

That's 1 query to fetch users, plus N more queries, one per user, to fetch their posts. With 10 users you might not notice. With 10,000 users, those extra round trips can turn an otherwise simple endpoint into a timeout.

The dangerous part isn't that this pattern exists. Every engineer knows N+1 queries are bad. The dangerous part is that the ORM's relational syntax looks the same whether it's doing this or a single join:

javascript

Relation loading isn't necessarily equivalent to the join you would write by hand. Depending on the ORM and its configuration, that one line can compile down to a single query with a join, or to a batch of separate queries stitched together in application code. Prisma, for example, defaults to the second approach for include on one-to-many relations: it batches the related rows with a SQL IN clause rather than emitting a native JOIN, unless you're on a relation mode or raw query that says otherwise. You can't reliably tell which one you got just by reading the application code. Turn on query logging and look.

Why this matters in production: N+1 doesn't necessarily mean N new connections. Queries can reuse the pool. The problem is the number of database round trips. Under concurrent traffic, many requests running their own N+1 loops can keep pooled connections busy for longer, increasing pool contention and request latency. This is where an ORM's convenience directly creates the incident.

The SQL your ORM actually generates is not the SQL you'd write

Turn on query logging for a week on a real Prisma or TypeORM project and read what comes out. It is rarely what you'd write by hand.

With nested includes, some ORMs, Prisma among them, can issue several separate queries and stitch the results together in application code instead of one query with joins. That's not a bug, it's a design trade-off, and it varies by ORM, version, and configuration. But it means EXPLAIN ANALYZE on the query you think you're running and the query Postgres actually saw can tell two different stories, and only one of them is visible in your codebase.

Why this matters in production: if you've read through the query planning module of the PostgreSQL In-Depth course, you know Postgres picks between a sequential scan, an index scan, and a bitmap heap scan based on cost estimates for the specific query it receives. An ORM that reshapes your intent into three smaller queries instead of one joined query gives the planner three separate decisions to make, rather than one plan for the operation as a whole. You lose the planner's ability to optimize across the whole operation.

The fix isn't abandoning the ORM. It's treating query logging as mandatory, not optional, and periodically running EXPLAIN ANALYZE on queries that matter to your application's hot paths or have noticeable latency.

Migrations: the auto-diff can silently drop data

Most ORMs generate migrations by diffing your schema file against the last known state and producing SQL. This works well for additive changes. It gets dangerous on renames.

Rename a column in your schema file, and a schema diff doesn't always have enough information to know that you intended a rename rather than a drop-and-add. Some ORMs can detect renames, while others may generate a drop-and-add migration depending on the schema change and migration workflow. The generated migration can be:

sql

instead of the safe version:

sql

The first version runs without error. It also silently discards every value in that column on deploy.

Why this matters in production: a migration that runs clean and drops a column's worth of data doesn't announce itself. Nobody gets an error. You find out when a support ticket comes in asking why a field is suddenly empty. This is one of the highest-leverage things to check before merging any ORM-generated migration: read the actual SQL it produces, not just the schema diff you wrote.

Connection pooling: every ORM has different defaults, and it matters more than ever in serverless

If you've read Connection Pooling in the Serverless Era, you already know that each connection Postgres accepts consumes server resources, whether or not it's doing anything, and enough concurrent connections becomes a significant resource cost. ORMs each ship their own default pool size, their own idle-timeout behavior, and their own assumptions about how long your process lives.

Those defaults were mostly written assuming a long-lived server process. In a serverless or edge function, where every invocation can be a fresh process, an ORM's default pool of, say, 10 connections means every cold start tries to open 10 connections to Postgres. Multiply that by concurrent invocations during a traffic spike and you can exhaust your database's max connection limit in seconds, well before you exhaust CPU or memory on the compute side.

Why this matters in production: this is a common cause of "it works locally, it falls over under load" for teams deploying ORM-based apps to serverless platforms. The fix usually involves an external pooler (PgBouncer, RDS Proxy, or a managed equivalent) sitting between your functions and Postgres, plus explicitly tuning the ORM's own pool size down, not trusting the default. One trap worth knowing before you reach for a pooler: PgBouncer's transaction-mode pooling, the mode most serverless setups need, doesn't preserve a session across queries, and ORMs that rely on prepared statements (Prisma and TypeORM both do, by default) can fail against it in ways that only show up under load. Check your pooler's mode against your ORM's prepared-statement behavior before you assume adding one fixes everything.

Write-heavy systems: where the ORM's convenience becomes the bottleneck

Everything above assumes a fairly typical application: reads dominate, writes happen when a user submits a form or an admin edits a record. That's most CRUD apps, and it's exactly where an ORM's ergonomics pay for themselves. It is not a blockchain indexer, an event pipeline, or anything else ingesting a continuous, high-volume stream of writes. Those systems are where an ORM's row-at-a-time model of the world stops being a convenience and starts being the bottleneck.

A blockchain indexer is a clean example because the write pattern is unforgiving: every new block can carry hundreds or thousands of events (transfers, contract calls, state changes), and all of them need to land in Postgres before the indexer can call itself caught up. Three places an ORM adds real cost in that path:

  • Row-by-row INSERT instead of COPY. Postgres's COPY protocol is built specifically for loading large volumes of rows in one streamed operation, and it is meaningfully faster than even a well-batched multi-row INSERT. Most ORMs don't expose COPY at all. Their bulk-insert APIs (createMany, bulkCreate, and similar) are a real improvement over inserting one row per statement, but under the hood they're still building INSERT statements with many value tuples, not streaming through COPY. For an indexer processing thousands of events per block, that gap compounds every single block.
  • Object hydration on the way in and out. An ORM that maps every row to and from a model instance is doing real work per row: validation, type coercion, applying defaults. That cost is invisible at CRUD volumes and very visible at ingestion volumes, where it adds CPU and allocation overhead to every event in the stream, not just the ones a human is waiting on.
  • More, smaller transactions than the workload needs. An ORM's ergonomic defaults tend toward one transaction per logical operation. At indexer-scale throughput, that can mean far more transaction and WAL overhead than a design that deliberately batches writes into fewer, larger transactions per block.

Why this matters in production: all three of these show up as the same symptom eventually, insert throughput that can't keep up with the source it's indexing, and the indexer falls further behind with every block. If you've read Taming PostgreSQL Replication Lag in Real-Time Blockchain Indexers, this is the same category of problem from a different angle: it's not always replication configuration that causes an indexer to lag, sometimes it's the write path itself, one ORM abstraction away from the bulk-loading tools Postgres actually gives you for this job.

None of this means an ORM is disqualified from a write-heavy system. It means the write path deserves the same scrutiny as any other hot path in this article: measure it, and if it's the bottleneck, that's exactly the place to drop to COPY, hand-batched multi-row INSERTs, or a dedicated bulk-loading library, and keep the ORM everywhere else it's not in the way.

When the abstraction stops being useful

Every ORM's query builder is designed around the common case: filter, sort, join, paginate. The moment your requirement moves past that, you're negotiating with the abstraction instead of using it. In practice, that means:

  • Window functions (running totals, ranking within groups) — most ORM query builders don't model these at all.
  • Recursive CTEs (org charts, threaded comments, category trees) — almost always raw SQL, even in projects that otherwise avoid it entirely.
  • Bulk operations — an ORM's .update() in a loop is N statements; a single UPDATE ... WHERE id = ANY($1) is one. The difference compounds fast on large batches.
  • ON CONFLICT (upsert) and RETURNING — supported by some ORMs, but often with a narrower API than the SQL clause itself allows, especially bulk upserts (ON CONFLICT DO UPDATE across many rows at once) and the newer OLD/NEW aliases in RETURNING.
  • SKIP LOCKED for queue-style workloads — the standard pattern for letting multiple workers pull from the same table without blocking on each other's locked rows. Most ORM query builders have no vocabulary for it at all, so it's raw SQL from the start.
  • Partial and expression indexes — PostgreSQL can use indexes defined with a WHERE clause or an expression, but the generated SQL still needs to satisfy the conditions that make those indexes usable. An ORM won't necessarily make that obvious from the application code.
  • JSONB queries with GIN indexes — the operators that make these fast (@>, ?, #>>) are Postgres-specific and rarely have first-class ORM support.
  • Row-level and advisory lockingSELECT ... FOR UPDATE, pg_advisory_lock, and friends are concurrency primitives most ORMs expose thinly or not at all.
  • Reading EXPLAIN ANALYZE output — no ORM will do this for you. It's the one skill that stays entirely yours no matter which tool you pick.

None of this means avoid the ORM for these cases. It means know, ahead of time, that you'll drop to raw SQL here, and treat that as normal rather than a sign something went wrong.

Is your ORM a black box? It depends which one

So what should you actually use?

Not all ORMs hide the same amount, and the trade-off usually runs opposite to how much boilerplate they remove:

ApproachAbstractionSQL visibilityBest fit
PrismaHighMediumCRUD-heavy product development
TypeORMHighMediumTraditional TypeScript applications, decorator-based models
SequelizeHighMediumMature Node.js codebases already built around it
DrizzleMediumHighType-safe apps that still want SQL-shaped queries
pg / node-postgresLowFullSQL-heavy code and hot paths

There's no winner in that table on purpose. A team shipping CRUD-heavy internal tools benefits enormously from Prisma's or Sequelize's ergonomics and rarely hits the edges above. A team running a write-heavy, high-traffic public API needs to know exactly which SQL is running on every hot path, and might be better served by Drizzle or hand-written queries where it matters most. Plenty of production codebases mix approaches deliberately: an ORM for the CRUD majority, raw SQL for the handful of queries that are actually hot.

The actual takeaway

The ORM isn't the problem. Forgetting that an ORM is generating database operations is the problem. It doesn't remove the need to understand what Postgres is doing, it just moves the moment you're forced to learn it, from day one of the project to the incident where your database is drowning in unnecessary queries because the ORM issued forty small UPDATE statements instead of one bulk operation.

Use the ORM for what it's good at: the 80% of your code that's routine CRUD, type-safe and fast to write. But keep query logging on, read the SQL your ORM generates for anything on a hot path, and read the actual migration SQL before it hits production, not just the schema diff. The ORM is a very good translator. It is still worth knowing enough of the language yourself to catch it when it gets a sentence wrong.

Discussion

0

Join the discussion

Loading comments...