Wise Hustlers — Digital Product & App Development Studio Logo
Get Consultation
By Wise Hustler Admin9/4/20268 min read

The N+1 Query Problem: How It Happens and How ORMs Hide It From You

The N+1 Query Problem: How It Happens and How ORMs Hide It From You

# The N+1 Query Problem: How It Happens and How ORMs Hide It From You

TL;DR: The N+1 problem happens when you fetch a list of records with one query, then fetch related data for each record with a separate query in a loop — turning what should be 1-2 database round trips into potentially hundreds. ORMs like Prisma hide it well when you use their relation-loading APIs correctly (include/select), and reintroduce it silently the moment you fetch related data manually inside a loop.

What the N+1 Problem Actually Is

The name describes the query count: 1 query to fetch a list of N records, plus N additional queries — one per record — to fetch something related to each. Fetch 50 blog posts, then loop over them to fetch each post's author individually, and you've run 51 queries where 2 would do.

It rarely shows up in development. A seeded database with 5 posts and 5 authors runs fine at 6 queries. The same code against a production table with 5,000 posts runs 5,001 queries, and now a page load that took 40ms takes 4 seconds — or times out. N+1 bugs are a classic case of code that's functionally correct and only fails at scale, which is exactly why they slip through code review and QA and show up as a production incident instead.

Why ORMs Make This Easy to Miss

Raw SQL forces you to think about round trips because you're writing the joins yourself. An ORM's whole pitch is to let you write post.author.name and not think about how that data got there — which is genuinely useful, but it also means the query count becomes invisible unless you go looking for it (via query logs, an APM tool, or a query-optimization feature built into the ORM).

Modern ORMs — Prisma, TypeORM, Django's ORM, ActiveRecord, Hibernate — all solve this the same general way: give you a declarative way to say "load this relation eagerly, in a batched query" (include, .select_related(), .includes, JOIN FETCH), so a single call produces at most a small, fixed number of queries no matter how many rows come back. The bug isn't that the ORM is bad at this. It's that nothing stops you from bypassing the batching API and fetching a relation manually inside a loop, and that code often looks just as clean as the correct version.

A Concrete Example: Triggering N+1 in Prisma

Here's a typical setup: a Post model with an authorId foreign key to a User model, and an endpoint that lists recent posts along with each author's name.

// BAD: triggers N+1
const posts = await prisma.post.findMany({
  where: { published: true },
  take: 50,
});

const postsWithAuthors = await Promise.all(
  posts.map(async (post) => {
    const author = await prisma.user.findUnique({
      where: { id: post.authorId },
    });
    return { ...post, authorName: author?.name };
  })
);

This code works, reads fine, and even runs concurrently thanks to Promise.all — which makes it feel efficient. It isn't. It's still 1 query for the post list plus 50 individual findUnique calls, one per post, all hitting the same users table. Turn on Prisma's query logging (log: ['query'] in the PrismaClient constructor, or Prisma Studio / Prisma Optimize) and you'll see 51 separate SELECT statements for a single request.

The Fix: Let Prisma Batch the Relation

// GOOD: one query (or two, batched) instead of 51
const posts = await prisma.post.findMany({
  where: { published: true },
  take: 50,
  include: {
    author: {
      select: { name: true },
    },
  },
});

const postsWithAuthors = posts.map((post) => ({
  ...post,
  authorName: post.author?.name,
}));

include tells Prisma to resolve the relation as part of the same query plan instead of leaving it to application code. Since Prisma 5.8, that resolution uses a relationLoadStrategy, and there are two options:

  • `join` (the default) — Prisma issues a single SQL query using a LATERAL JOIN on PostgreSQL/CockroachDB (or a correlated subquery on MySQL) and returns posts with their authors nested in one round trip.
  • `query` — Prisma issues one query per table (e.g., one for posts, one WHERE id IN (...) for the matching authors) and joins the results in the application layer. This is still O(1) additional queries regardless of row count, just not a single SQL statement.
const posts = await prisma.post.findMany({
  where: { published: true },
  include: { author: { select: { name: true } } },
  relationLoadStrategy: "query", // or "join"
});

relationLoadStrategy requires the relationJoins preview feature flag in schema.prisma and is available on PostgreSQL, CockroachDB, and MySQL 8.0.14+; it's still listed as a preview feature in Prisma's docs as of Prisma 7. Either strategy fixes the N+1 pattern — the difference is whether the join happens in the database or in your application code, which matters for query plan complexity and payload size on very wide relations, not for the query count itself.

Detecting N+1 Before It Reaches Production

The pattern above is easy to write by accident, especially when a relation gets added to a loop later by someone who doesn't realize a batching alternative exists. A few concrete ways to catch it:

1. Turn on Prisma's query logging in development and staging. A request that logs 50+ near-identical SELECT statements is the tell.

2. Use [Prisma Optimize](https://www.prisma.io/docs/orm/prisma-client/queries/advanced/query-optimization-performance), Prisma's built-in tool for flagging repeated queries and suggesting include/select fixes.

3. Watch your APM tool's per-endpoint query count, not just latency. A slow endpoint with a high query count is almost always an N+1, not a slow single query.

4. Grep for `.map()` / `for` loops that call `await prisma.*` inside them. It's a blunt heuristic, but it catches most instances.

N+1 Isn't Prisma-Specific

The same shape of bug exists in every ORM that supports lazy-loaded relations: Django (fixed with .select_related()/.prefetch_related()), Rails ActiveRecord (fixed with .includes), Hibernate/JPA (fixed with JOIN FETCH or entity graphs), TypeORM (fixed with relations or leftJoinAndSelect). If you're working in a GraphQL API rather than a single REST endpoint, the risk is worse by default, because a naive resolver-per-field pattern re-triggers a fetch for every parent object independently — that's the scenario DataLoader was built for: it batches and deduplicates the individual lookups issued within a single tick of the event loop into one query, whether or not your ORM does relation batching on its own.

Where This Actually Bites Teams

N+1 bugs tend to survive code review because the code is readable and the logic is correct — it's a performance defect, not a correctness one, and it only becomes visible at production data volumes. Teams inheriting a legacy Rails or Django app, or a Node/Prisma codebase that grew fast without query auditing, often find several of these hiding in list endpoints, admin dashboards, and report generators — anywhere a page renders a table with related data per row. If you're auditing an existing codebase for performance issues as part of a broader web development engagement, query-count profiling on your top list/detail endpoints is one of the highest-signal, lowest-effort checks you can run.

FAQ

Does Prisma's `include` always prevent N+1?

Yes, for the relation you include — Prisma resolves it in a bounded number of queries (one join, or one query per related table) regardless of how many rows come back. N+1 reappears if you fetch a different relation manually in a loop after the initial query, since that manual fetch isn't part of the include plan.

Is the `join` relationLoadStrategy always faster than `query`?

Not necessarily. A single SQL join can return a lot of duplicated column data across a wide one-to-many relation, and can produce a more complex query plan than two simpler, separately-indexed queries. Prisma's docs recommend testing both strategies against your actual schema and data shape rather than assuming one is universally better.

Can I have N+1 with raw SQL, not just an ORM?

Yes — the pattern is about how many round trips your application code makes, not which query layer you use. Raw SQL just makes each round trip more visible because you wrote the query yourself, which is part of why ORMs get blamed for a bug that's really about loop structure.

How do I know if my app has an N+1 problem right now?

Enable query logging (or a tool like Prisma Optimize) on a staging environment with production-scale data and look at the query count per request, not just response time. An endpoint whose query count scales linearly with the number of rows returned has an N+1 somewhere in it.

Sources