Skip to content
SJ
All writing
10 min read

Judge an ORM by Its Escape Hatch

Every ORM bets you will not need the database's real features. That bet always loses eventually, so evaluate the exit, not the query builder.

PrismaMongoosePostgreSQLTypeScriptBackend

An ORM is chosen on the strength of its query builder and regretted on the strength of its limits. The builder handles the eighty percent of queries that are a filter and a join, which were never the hard part. The remaining twenty percent — the window function, the recursive CTE, the bulk upsert, the query that needs a specific index hint — is where you find out what you actually bought.

The bet every ORM makes

The pitch is that you write objects and the library writes SQL, so you need not think about the database. This is true for simple cases and becomes false at the exact moment performance starts to matter, because performance work is thinking about the database.

So the useful framing is not “which ORM has the nicest API.” It is: when I need to do something this tool did not anticipate, what happens? Do I drop cleanly into SQL and keep my types, or do I fight the abstraction, or do I abandon it for that query and lose safety everywhere it touched?

Where the type-safety actually stops

Generating types from the schema is the genuine advance of the current generation of tools, and it is worth being precise about its boundary.

Inside the query builder, types are real: select three columns and the result type has three properties, so a typo is a compile error and a renamed column breaks the build rather than production. That is a substantial improvement over hand-written interfaces that drift from the schema silently.

It stops in three places, and all three are common:

  • Raw queries. The moment you write SQL as a string, the result is whatever you assert it is. Most tools let you supply a type parameter, which is an assertion, not a check — nothing verifies your claim against the actual columns.
  • Aggregations and computed columns. A COUNT or a computed expression often lands as any, or as a type that does not reflect the database returning a bigint as a string.
  • Nullability from outer joins. A left join makes every column of the joined table nullable. Whether your tool reflects that in the type is worth testing before you rely on it, because a string that is sometimes null is worse than no type at all.

Mongoose sits differently here. Its schema is defined in application code rather than derived from the database, which means the types describe what your application intends — and the database, which does not enforce them, may contain documents from three schema versions ago that violate every one. A typed read of untyped storage is a claim, not a guarantee. Validation on write only applies to writes that went through Mongoose.

The N+1 you get for free

Lazy loading is the ORM feature most likely to cause a production incident, because it is invisible at the call site:

const orders = await repo.find({ where: { tenantId } });
for (const o of orders) {
  console.log(o.customer.name);   // a query. per order. silently.
}

Nothing about that loop suggests I/O. It reads like property access, which is the entire problem with the abstraction: it hides the distinction between memory and network, which is the distinction that determines whether this endpoint takes 8ms or 800ms.

Tools that require explicit inclusion — where you declare relations up front and unfetched ones are simply absent from the type — trade convenience for the property that a query is always visible in the code. That is the better default, and if your tool offers lazy loading, turning it off is usually correct.

The related trap is over-eager fixing: including three one-to-many relations in one query multiplies rows, and some ORMs then deduplicate in memory, so you transfer thousands of rows to build twenty objects and the query log looks innocent. Check what SQL is actually emitted. Every serious tool can log it, and turning that on in development is the single highest-value setting available to you.

Migrations you can run on a live database

Migration tooling is usually evaluated on developer experience — how easily it generates a migration from a schema change. The more important question is what happens when that migration runs against a table with fifty million rows while traffic is on it.

Auto-generated migrations are largely unaware of this. A generated ALTER TABLE adding a non-null column with a default, or creating an index without CONCURRENTLY, takes a lock that blocks writes for the duration. On a small table this is imperceptible. On a large one it is an outage, produced by a migration that looked identical in staging.

The practical discipline is to treat generated migrations as drafts. Read the SQL. For anything on a large table, use the expand-and-contract shape: add the new nullable column, backfill in batches, start writing both, switch reads, then drop the old one — several deploys rather than one, and no long lock anywhere.

A tool that cannot express “create this index concurrently, outside a transaction” is a tool you will be working around on your busiest table.

The escape hatch is the feature

The thing worth evaluating before adopting. A good escape hatch has four properties:

  • Parameterised by construction. A tagged template that parameterises interpolated values makes the safe path the default. A function taking a plain string invites concatenation, which is how SQL injection survives in codebases that have an ORM.
  • Uses the same connection and transaction. If a raw query cannot participate in the transaction your ORM opened, you cannot mix them, and you will end up doing everything raw in that code path.
  • Composable with the builder. Being able to drop a raw fragment into one clause of an otherwise-typed query is far more useful than an all-or-nothing switch.
  • Honest about types. The point is that you supply the type and the tool does not pretend to have verified it.
// raw where it earns it, typed at the boundary, parameterised by construction
const rows = await db.$queryRaw<{ tenantId: string; p95: number }[]>`
  SELECT tenant_id AS "tenantId",
         percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95
  FROM requests
  WHERE created_at > ${since}
  GROUP BY tenant_id
`;

That query is not expressible in any query builder worth using, and it should not be. The correct outcome is a tool that gets out of the way for it and stays useful for the other ninety percent.

The repository on top is usually one abstraction too many

A common instinct is to wrap the ORM in a repository layer so the ORM could be swapped later. It rarely pays.

The ORM is already a repository — that is what it is. Wrapping it produces a second interface that must expose enough of the underlying capability to be useful, which means it either reimplements the query API or restricts it, and the restricted version is the one that gets bypassed the first time someone needs a specific query. What you end up maintaining is an abstraction that leaks by design, in service of a migration that will not happen — and if it does happen, the repository will not save you, because the semantics differ far below the interface.

The version that does pay is narrower: a module per aggregate that owns its queries and exposes domain-meaningful functions — findOverdueInvoices(tenantId) rather than a generic find wrapper. That is not portability, it is cohesion, and it means the day you need to hand-write that query, exactly one file changes.

The short version

Assume you will need the database's real features, and pick the tool whose exit is cleanest: parameterised raw queries, same transaction, composable, honest about types. Turn off lazy loading and turn on query logging in development. Read the SQL of every generated migration before it touches a large table. And skip the repository layer unless it is organising queries by domain rather than pretending the database is replaceable.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.