Back to Blog
Backend15 min readJun 2026

ORMs and the N+1 Query Problem

The endpoint that flies in development and dies in production. Learn what an ORM really does, how lazy loading silently fires a query per row, and how to detect and kill the N+1 problem for good.

ORMSQLPerformanceN+1
SB

Sri Balaji

Founder

On this page

The endpoint that lies to you

Who this is for

You built an API that lists blog posts with their authors. On your laptop with 10 rows it returns in 12ms. You ship it. With 10,000 rows in production it takes 8 seconds and pins a database CPU. Nothing in your code looks slow. This article is for the developer who has met, or is about to meet, the **N+1 query problem**, the single most common hidden performance killer in ORM-backed apps.

Here is the cruel part: the bug is invisible in the code. You wrote a clean loop over posts and read post.author.name. The ORM quietly turned that one innocent attribute access into a fresh round-trip to the database, once per row. Ten rows, ten extra queries. Ten thousand rows, ten thousand extra queries. The code never changed; only the data grew.

To see why, we first need to understand what an ORM is actually doing on your behalf, and the impedance mismatch it is trying to paper over.

What an ORM does (and the impedance mismatch)

An ORM (Object-Relational Mapper) maps rows in relational tables to objects in your programming language. Instead of writing SQL and hand-stitching result sets into objects, you call User.find(1) and get a User instance with typed fields and methods. Popular ones: Prisma and TypeORM (TypeScript), Hibernate (Java), ActiveRecord (Rails), SQLAlchemy and Django ORM (Python).

It exists because of the object-relational impedance mismatch: objects and relational tables model the world differently. Objects have references, inheritance, and nested collections; tables have rows, columns, and foreign keys joined at query time. A User object that owns a list of Post objects is, in the database, two tables linked by a user_id column. The ORM's job is to translate between these two worlds, and most of the time it does it beautifully. The N+1 problem is what happens when that translation goes wrong silently.

Making one trip to the store with a full shopping listOne query with a JOIN that fetches posts AND their authors together
Driving back to the store separately for each item you forgotLazy loading firing a new SELECT every time you touch post.author
Ten items = ten round trips, each with its own drive there and backTen posts = ten extra queries, each with its own network + parse + plan cost
It feels fine for two items; it is absurd for two hundredFast with 10 rows, dead with 10,000, the cost is hidden until the data grows
Lazy loading is convenient until you do it in a loop.

How lazy loading fires N+1 queries

Most ORMs default to lazy loading: a related object is not fetched until you actually access it. post.author looks like a plain field read, but the first time you touch it the ORM runs a SELECT * FROM users WHERE id = ?. Do that inside a loop over N posts and you get 1 query for the list, then N queries for the children. That is the N+1: 1 + N queries where 1 would do.

1 querylooplooploop
App

loop over posts

Query 1

SELECT * FROM posts

Query 2

author for post #1

Query 3

author for post #2

Query N+1

author for post #N

The N+1 pattern: one query for the list, then a separate query per row.

  1. 1

    Fetch the list

    The ORM runs one query: SELECT * FROM posts. So far, so good, one round trip.

  2. 2

    Start iterating

    Your code loops over the result and reads post.author.name for each post.

  3. 3

    Lazy load fires

    Each post.author access is a cache miss, so the ORM issues SELECT * FROM users WHERE id = ?, one query per post.

  4. 4

    The count explodes

    100 posts means 1 + 100 = 101 queries. Each carries network latency, query parsing, and planning overhead on top of the actual work.

The N+1 in code, and the SQL it emits

Here is the trap in a typical TypeScript ORM. It reads cleanly and passes review. The damage only shows in the query log.

typescript
// ❌ N+1: looks innocent, fires one query per post
const posts = await postRepo.find(); // 1 query

for (const post of posts) {
  // each access to post.author lazily loads it
  console.log(post.title, 'by', post.author.name); // +1 query EACH
}

// 100 posts  ->  1 + 100 = 101 queries
sql
-- the SQL the ORM actually emits
SELECT * FROM posts;                       -- the 1
SELECT * FROM users WHERE id = 1;          -- +1
SELECT * FROM users WHERE id = 2;          -- +1
SELECT * FROM users WHERE id = 3;          -- +1
-- ... 97 more ...
SELECT * FROM users WHERE id = 100;        -- +1

The fix is eager loading: tell the ORM up front that you need the authors, and it fetches them in a single query, either a JOIN or a second batched IN (...) lookup. Same data, 1 or 2 queries instead of 101.

typescript
// ✅ Eager load the relation up front
const posts = await postRepo.find({
  relations: { author: true }, // TypeORM
});
// Prisma equivalent:
// prisma.post.findMany({ include: { author: true } })

for (const post of posts) {
  console.log(post.title, 'by', post.author.name); // no extra query
}

// 100 posts  ->  1 (or 2) queries, regardless of count
sql
-- one JOIN fetches everything
SELECT posts.*, users.id AS u_id, users.name AS u_name
FROM posts
LEFT JOIN users ON users.id = posts.user_id;

-- or a single batched lookup (the "dataloader" shape):
SELECT * FROM posts;
SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100); -- ONE query for all authors

JOIN vs batched IN

A JOIN is one query but duplicates the parent columns for every child (wasteful for one-to-many with many children). A batched `IN (...)` lookup, the **DataLoader** pattern, issues two queries but no duplication, and it deduplicates repeated ids automatically. For deeply nested GraphQL resolvers, DataLoader batching is usually the cleaner win; for flat list endpoints, a JOIN is simplest.

ORM vs query builder vs raw SQL

An ORM is not your only tool. A query builder (Knex, Kysely, jOOQ) gives you composable, type-safe SQL without the object-mapping magic. Raw SQL gives you total control. The right choice depends on how much the query matters.

AspectORMQuery builderRaw SQL
ProductivityHighest, CRUD for freeMedium, you write the shapeLowest, every query by hand
Control over SQLLow, generated for youHigh, you compose itTotal, exactly what you write
N+1 riskHigh, lazy loading hides itLow, joins are explicitNone, you wrote the join
Best forStandard CRUD, most endpointsReports, complex filtersHot paths, analytics, tuned queries
Pick the lowest-magic tool that still keeps you productive.

When to drop to raw SQL: a reporting query with window functions, a recursive CTE, a query you have profiled and need to control the exact plan of, or a bulk operation where the ORM's row-by-row hydration is the bottleneck. Most apps are a healthy mix: ORM for 90% of CRUD, query builder or raw SQL for the 10% of hot, complex, or analytical paths. Reaching for raw SQL is not a defeat, it is engineering judgement.

Detecting N+1 before your users do

If you cannot see your queries, you cannot fix them

N+1 is invisible in code review. The only reliable way to catch it is to **make the query count observable**. Three layers, weakest to strongest: log queries in dev, assert query counts in tests, and watch query-per-request in an APM.

  1. Log every query in development. Turn on the ORM's query logging (logging: true in TypeORM, log: ['query'] in Prisma, echo=True in SQLAlchemy). Hit the endpoint once and watch the console. If you see the same SELECT ... WHERE id = ? repeated with different ids, that is an N+1 staring at you.
  2. Assert query counts in tests. The strongest defense: wrap a request in a counter and fail the test if it issues more than the expected number of queries. This turns a silent regression into a red build, a new lazy-loaded relation can never sneak back in.
  3. Watch APM in production. Tools like Datadog, New Relic, or OpenTelemetry traces show 'queries per request' and break a slow endpoint into its spans. An endpoint with 200 nearly-identical DB spans is the unmistakable signature of N+1 at scale.
typescript
// A test that locks the query count, the regression-proof fix
import { dataSource } from './db';

test('GET /posts issues a constant number of queries', async () => {
  let queryCount = 0;
  dataSource.logger = { logQuery: () => queryCount++ } as any;

  await listPostsWithAuthors(); // the handler under test

  // 2 queries no matter how many posts exist (list + batched authors)
  expect(queryCount).toBeLessThanOrEqual(2);
});

The repository pattern, and migrations vs auto-sync

Two structural habits keep ORM code healthy as it grows.

Centralise data access with a repository

The repository pattern puts all queries for an entity behind one interface, PostRepository.findWithAuthors() instead of ad-hoc find() calls scattered across controllers. The payoff for N+1 is direct: eager-loading decisions live in one place, you can audit and test them centrally, and a fix applies everywhere at once instead of being re-introduced in the next handler someone writes.

typescript
// One place owns the loading strategy
class PostRepository {
  // every caller gets authors eagerly, no N+1 surface area
  findWithAuthors() {
    return this.repo.find({ relations: { author: true } });
  }
}

Use migrations, never auto-sync, in production

Most ORMs offer an auto-sync mode (synchronize: true) that reshapes your database to match your entity classes on startup. It is wonderful for a weekend prototype and catastrophic in production: it can silently drop a column, and the data in it, to match a renamed field. Migrations are versioned, reviewable, reversible SQL scripts that record exactly how the schema changes over time. Auto-sync for local scratch work only; migrations for anything you cannot afford to lose.

synchronize: true has eaten production data

Treat schema changes like code: a migration file in version control, reviewed in a PR, applied as a deliberate step in your deploy. Never let an ORM rewrite your production schema on boot.

Common mistakes that cost hours

  1. Testing with 10 rows. N+1 is invisible at small scale. Seed your dev database with realistic volume, or you will ship the bug every time.
  2. Fixing the loop but not the nested one. Eager-loading authors but then looping over each author's comments just moves the N+1 one level down. Trace the whole object graph you actually touch.
  3. Over-eager loading. The opposite trap: include everything 'to be safe' and you fetch a megabyte of relations you never read. Load exactly the relations the endpoint uses, no more, no less.
  4. **Selecting SELECT * when you need two columns.** Fixing the query count but still dragging every column (including large text/JSON blobs) over the wire. Project only the fields you need.
  5. Leaving `synchronize: true` on in production. Convenient in dev, a data-loss landmine in prod. Switch to migrations before you have anything to lose.
  6. Trusting the ORM to index for you. It will not. A JOIN on an unindexed foreign key is still slow, see database indexing and query performance.

Takeaways

The whole article in seven lines

  • An ORM maps rows to objects to bridge the object-relational impedance mismatch, handy, but it hides the SQL.
  • Lazy loading turns `post.author` into a query, and inside a loop that becomes 1 + N queries: the N+1 problem.
  • It is invisible in code and fast with 10 rows, then dies at 10,000. Test with realistic data.
  • Fix it with eager loading: a JOIN or a batched `IN (...)` / DataLoader lookup. 101 queries become 1 or 2.
  • Detect it three ways: log queries in dev, assert query counts in tests, watch queries-per-request in APM.
  • Centralise loading in a repository; use versioned migrations, never `synchronize: true`, in production.
  • Drop to raw SQL or a query builder for hot, complex, or analytical paths, that is judgement, not failure.

Where to go next

Killing N+1 is one layer of database performance. The next layers are making each query fast, modelling your data well, and avoiding the query altogether when you can.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.