Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Oct 4, 2026·15 min read

ORM Internals: N+1, Lazy Loading & Eager Loading

An ORM is a state synchronization contract between your object graph and your database rows — not a query abstraction layer. This article explains why N+1 is an ORM default behavior (not a user error), when eager loading produces Cartesian explosion, and how the DataLoader pattern is the minimum correct implementation for any list-loading path.

Technical Series

Advanced Database & State Management

Part 5 of 5

ORM Internals: N+1, Lazy Loading & Eager Loading

A database is not a dumb storage box — it is a contract between your write path, your read path, and your consistency guarantees. Every design decision you defer becomes a production incident you eventually own. At the application layer, the most deferred decision is this: knowing what SQL your ORM is actually generating. Engineers trust the ORM to "handle it" and discover in production, weeks after launch, that listing 100 blog posts fires 101 SQL queries — one for the list, one for each author. The N+1 problem is an ORM default behavior, not a user error. This article explains why it happens, how to catch it, and the minimum correct implementation for every list-loading path.

Architectural Note

This is Part 5 of the Advanced Database & State Management series. The N+1 problem is fundamentally a query plan problem — understanding B-Tree index costs from Part 1 — Database Internals gives useful context for why N individual point lookups are always slower than one batched range query.

Architectural Note

Cross-series reference: The DataLoader pattern taught here is the ORM-layer equivalent of the resolver-layer DataLoader in the GraphQL Backend & API Design series. The same batch-and-cache principle applies — the context differs (SQL vs GraphQL resolver), the contract is identical.


1. The N+1 Problem: Why It Is Invisible in Development

The N+1 problem occurs when an application loads a collection of N parent records, then executes one additional query per parent to load its related children — resulting in N+1 total queries instead of the correct answer of 1 (or 2 at most).

1.1 How It Manifests

TYPESCRIPT
// ❌ Classic N+1: fetching blog posts and their authors
// This looks innocent. The ORM hides what it is doing.

const posts = await prisma.post.findMany({ take: 100 });
// → Query 1: SELECT * FROM posts LIMIT 100

for (const post of posts) {
  const author = await prisma.user.findUnique({
    where: { id: post.authorId },
  });
  // → Query 2: SELECT * FROM users WHERE id = 1
  // → Query 3: SELECT * FROM users WHERE id = 2
  // → Query 4: SELECT * FROM users WHERE id = 3
  // ... 100 more queries
  console.log(`${post.title} by ${author.name}`);
}
// Total: 101 queries. In dev: feels fine (sub-ms each).
// In production with real latency: 101 × 5ms = 505ms for one page load.

1.2 Why It Is Invisible in Development

Development database:
  SELECT * FROM users WHERE id = 1  →  0.3ms  (localhost, warm cache)
  × 100 queries                      →  30ms total
  → "Feels fast. Ship it."

Production database:
  SELECT * FROM users WHERE id = 1  →  4ms  (network RTT + connection overhead)
  × 100 queries                      →  400ms total
  → "The blog page is slow." (one week after launch)

Development ORMs respond in sub-millisecond because the database is on localhost, the connection pool is warm, and the data fits in memory. The N+1 pattern only manifests at scale — and no standard unit test catches it, because unit tests do not measure query count.

Performance / Safety Warning

Adding an index to the users.id column does not fix N+1. An index makes each of the 100 queries faster — but you still make 100 queries. The fix is to eliminate the queries, not to speed up each one.


2. ORM Query Generation: What the ORM Actually Emits

Understanding N+1 requires knowing exactly what SQL your ORM generates for each pattern. The ORM's query generation is deterministic — you can always find out.

2.1 The Three Patterns and Their SQL

TYPESCRIPT
// Pattern A — N+1 (wrong)
// Prisma emits: SELECT * FROM posts (1 query)
// Then per post: SELECT * FROM users WHERE id = $1 (N queries)
const posts = await prisma.post.findMany();
for (const post of posts) {
  const author = await prisma.user.findUnique({ where: { id: post.authorId } });
}

// Pattern B — Two queries (correct, but not a JOIN)
// Prisma emits:
//   Query 1: SELECT * FROM posts
//   Query 2: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
// This is what Prisma does when relationLoadStrategy = "query"
const posts = await prisma.post.findMany({
  include: { author: true },  // With relationLoadStrategy: "query" (Prisma 5.x default)
});

// Pattern C — Single JOIN query (most efficient for small relations)
// Prisma emits:
//   SELECT posts.*, users.* FROM posts LEFT JOIN users ON users.id = posts.authorId
// This is what Prisma does when relationLoadStrategy = "join"
const posts = await prisma.post.findMany({
  include: { author: true },
  // With: { prisma.$extends({ ... relationLoadStrategy: "join" }) }
});

2.2 Prisma 5.x relationLoadStrategy

TYPESCRIPT
// ✅ Prisma 5.x: control the query strategy per-query
const posts = await prisma.post.findMany({
  relationLoadStrategy: 'join',  // Single JOIN query — best for one-to-one, one-to-many
  include: { author: true },
});

const posts = await prisma.post.findMany({
  relationLoadStrategy: 'query',  // Two queries — best for large many-to-many
  include: { tags: true },         // avoids Cartesian explosion on many-to-many
});
Pro Tip & Optimization

Use relationLoadStrategy: 'join' for one-to-one and one-to-many relationships. Use relationLoadStrategy: 'query' (two queries) for many-to-many relationships — a JOIN on many-to-many produces a Cartesian product that Prisma must deduplicate, which can be slower than two batched queries for large result sets.

N+1 query loop waterfall emitting 101 queries vs eager loading single JOIN query emitting 1 query
N+1 query loop waterfall emitting 101 queries vs eager loading single JOIN query emitting 1 query

3. Lazy Loading: The Hidden SELECT

Lazy loading fires a SQL query when you access a property on an ORM entity. It is the mechanism behind N+1 — and it is invisible at the call site.

3.1 How Lazy Loading Works

TYPESCRIPT
// TypeORM example — lazy loading with Promise-based relations
// Entity definition
@Entity()
class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToOne(() => User, { lazy: true })  // lazy relation
  author: Promise<User>;  // Promise signals lazy loading
}

// ❌ This looks like a property access — it fires a SELECT
const post = await postRepository.findOne({ where: { id: 1 } });
// → Query 1: SELECT * FROM posts WHERE id = 1

const author = await post.author;
// → Query 2: SELECT * FROM users WHERE id = <post.authorId>
// ↑ Hidden SQL. Not obvious at the call site. Multiplied by N in a loop.

3.2 The JSON Serializer Trap

TYPESCRIPT
// ❌ Extremely dangerous: lazy loading inside JSON.stringify (or Express res.json())
// res.json() calls .toJSON() which triggers property access on all lazy relations

app.get('/posts', async (req, res) => {
  const posts = await postRepository.find();
  // ↑ Query 1: SELECT * FROM posts (100 rows)

  res.json(posts);
  // ↑ res.json() serializes each post
  //   Each post.author access → SELECT * FROM users WHERE id = $1
  //   → 100 additional queries — COMPLETELY INVISIBLE in the controller code
});
Performance / Safety Warning

JSON serialization is the most dangerous lazy loading trigger because it happens outside the controller's explicit code. The res.json() call looks innocent. The N queries are fired deep inside the serializer. Use a query logger with log_min_duration_statement = 0 in staging to catch this pattern before it reaches production.


4. Eager Loading: Correct Patterns and the Cartesian Trap

4.1 One-to-Many: Use include

TYPESCRIPT
// ✅ Prisma: one author per post — single JOIN or two queries (no Cartesian explosion)
const posts = await prisma.post.findMany({
  include: {
    author: true,      // one-to-one → safe with JOIN
    comments: true,    // one-to-many → safe with JOIN (bounded by post count)
  },
});
// Generated SQL (join strategy):
// SELECT posts.*, users.*, comments.*
//   FROM posts
//   LEFT JOIN users ON users.id = posts.author_id
//   LEFT JOIN comments ON comments.post_id = posts.id

4.2 Many-to-Many: Avoid the Cartesian Product

TYPESCRIPT
// ❌ Many-to-many with JOIN: Cartesian explosion
// posts × tags = (100 posts) × (avg 20 tags each) = 2000 rows returned
// Then Prisma deduplicates in application memory → expensive for large result sets
const posts = await prisma.post.findMany({
  relationLoadStrategy: 'join',
  include: { tags: true },  // many-to-many via join table
});
// SQL emits: SELECT posts.*, tags.*
//              FROM posts
//              JOIN _PostToTag ON ...
//              JOIN tags ON ...
// Returns 2000 rows for 100 posts — deduplicated by Prisma, but 2000 rows traversed

// ✅ Many-to-many with two queries: no Cartesian explosion
const posts = await prisma.post.findMany({
  relationLoadStrategy: 'query',  // Query 1: posts, Query 2: tags WHERE post_id IN (...)
  include: { tags: true },
});
// Query 1: SELECT * FROM posts → 100 rows
// Query 2: SELECT * FROM tags WHERE post_id IN (1,2,...,100) → ≤ 2000 rows
// Prisma joins in memory, but only reads 2000 rows (not 2000 × columns of posts)

5. DataLoader: The Minimum Correct Implementation

The DataLoader pattern was introduced by Facebook for GraphQL resolver batching but applies equally to any list-loading path in a server that handles many concurrent requests.

5.1 The Batch-and-Cache Contract

TYPESCRIPT
import DataLoader from 'dataloader';

// ✅ Create a DataLoader per request (not per module — not a singleton)
function createUserLoader() {
  return new DataLoader<number, User>(async (userIds) => {
    // DataLoader coalesces all calls within the same event-loop tick
    // into a single batch call with all requested IDs
    const users = await prisma.user.findMany({
      where: { id: { in: userIds as number[] } },
    });

    // Must return results in the SAME ORDER as userIds
    const userMap = new Map(users.map((u) => [u.id, u]));
    return userIds.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
  });
}

// Usage in Express middleware — one loader per request
app.use((req, res, next) => {
  req.loaders = {
    user: createUserLoader(),
  };
  next();
});

// In a resolver or controller — looks like individual lookups, batched automatically
async function getPostWithAuthor(postId: number, loaders: Loaders) {
  const post = await prisma.post.findUnique({ where: { id: postId } });
  const author = await loaders.user.load(post!.authorId);
  // If 100 posts are loaded in parallel, all 100 loaders.user.load() calls
  // are coalesced into ONE: SELECT * FROM users WHERE id IN (...)
  return { ...post, author };
}

5.2 DataLoader Timing: The Event-Loop Tick

Single event-loop tick:
  t=0ms: Request A calls loaders.user.load(1)
  t=0ms: Request B calls loaders.user.load(2)
  t=0ms: Request C calls loaders.user.load(1)  ← duplicate
  t=0ms: DataLoader tick boundary — batch fires
    → SELECT * FROM users WHERE id IN (1, 2)  ← 1 query for 3 calls
    → cache deduplicates id=1 (returned from cache to both A and C)
  t=4ms: all three requests receive their User objects
Crucial Requirement

DataLoader caches within a single request lifecycle only — it is not a global cache. A new DataLoader instance must be created per request (or per GraphQL execution context). If you instantiate DataLoader as a module-level singleton, all requests share the same cache — requests see each other's data (correctness bug) and the cache grows unboundedly (memory leak).

DataLoader microtask event-loop tick coalescing individual load calls into a single batched SQL query and deduplicating cached IDs
DataLoader microtask event-loop tick coalescing individual load calls into a single batched SQL query and deduplicating cached IDs

5.3 Identity Map and Unit of Work: Change Tracking Internals

TYPESCRIPT
// TypeORM EntityManager: the Identity Map pattern
const entityManager = dataSource.createEntityManager();

const user1 = await entityManager.findOne(User, { where: { id: 1 } });
const user2 = await entityManager.findOne(User, { where: { id: 1 } });

console.log(user1 === user2);  // true — same object reference
// EntityManager tracks every loaded entity. A second load of the same PK
// returns the cached object — no second SQL query.

// ❌ Long-running workers: the Identity Map grows forever
// A worker that processes 100K events — EntityManager loads 100K User objects
// and retains all of them in memory.
async function processEvents(events: Event[]) {
  const em = dataSource.createEntityManager();
  for (const event of events) {
    await em.findOne(User, { where: { id: event.userId } });
    // em retains every loaded User — memory grows with each iteration
  }
}

// ✅ Clear the Identity Map periodically in long-running workers
async function processEvents(events: Event[]) {
  const em = dataSource.createEntityManager();
  for (let i = 0; i < events.length; i++) {
    await em.findOne(User, { where: { id: events[i].userId } });
    if (i % 1000 === 0) {
      em.clear();  // Release all tracked entities — prevents memory leak
    }
  }
}

6. Query Logging: The Only Reliable N+1 Detector

SQL
-- PostgreSQL: log all queries that take longer than 0ms (every query)
-- Set in postgresql.conf or per session for staging environments
log_min_duration_statement = 0   -- logs every query with its duration
log_statement = 'all'            -- also logs queries with zero duration

-- Pattern to look for in pg_log:
-- Many near-identical queries with different parameter values in rapid succession:
-- 2026-09-06 12:00:00 LOG: duration: 2.1ms  statement: SELECT * FROM users WHERE id = 1
-- 2026-09-06 12:00:00 LOG: duration: 2.3ms  statement: SELECT * FROM users WHERE id = 2
-- 2026-09-06 12:00:00 LOG: duration: 1.9ms  statement: SELECT * FROM users WHERE id = 3
-- ↑ This pattern at high frequency = N+1 in production
TYPESCRIPT
// Prisma: enable query logging in development/staging
const prisma = new PrismaClient({
  log: [
    { emit: 'event', level: 'query' },
  ],
});

prisma.$on('query', (e) => {
  console.log(`Query: ${e.query}`);
  console.log(`Duration: ${e.duration}ms`);
});

// ✅ Slow query threshold in staging:
// Set log_min_duration_statement = 100 (log queries > 100ms)
// An N+1 generating 100 individual queries of 5ms each won't be caught here
// → Use log_min_duration_statement = 0 for N+1 detection specifically
Pro Tip & Optimization

The most effective N+1 detection setup: enable log_min_duration_statement = 0 in staging and run your full test suite against the staging database. Count identical query patterns in the log. Any pattern that repeats N times with only a different parameter value is an N+1.


Summary

Concept Rule
N+1 cause ORM lazy loading is the default — every collection access fires N additional queries unless you explicitly opt into eager loading
N+1 visibility Sub-millisecond in dev; hundreds of milliseconds in production — code review will not catch it
Fix N+1 include / join for one-to-one and one-to-many; query strategy for many-to-many; DataLoader for resolver/service layer
Lazy loading in serializer res.json() triggers lazy loading on all properties — the most dangerous N+1 pattern because it is invisible in controller code
Cartesian explosion Many-to-many with JOIN returns N×M rows — use relationLoadStrategy: 'query' (two queries) instead
DataLoader timing Coalesces all .load() calls within one event-loop tick into one batched query
DataLoader scope Per-request instance only — module-level singleton causes cross-request cache pollution and memory leak
Identity Map ORM tracks every loaded entity — EntityManager.clear() is mandatory in long-running workers to prevent unbounded memory growth
Query logging log_min_duration_statement = 0 in staging is the only reliable N+1 detector — code review and unit tests are insufficient
Prisma relationLoadStrategy 'join' for one-to-one/one-to-many; 'query' for many-to-many — controls whether Prisma emits a JOIN or a second batched query

What's Next

You have completed the core arc of the Advanced Database & State Management series. For the next layer — how these data patterns manifest in API design across service boundaries — see the GraphQL Backend & API Design series, which covers DataLoader in the resolver context, schema design as a domain contract, and query depth limiting as a production-operations requirement.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#ORM#Prisma#N+1 Problem#Query Optimization
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.