Skip to content
Innovialabs

The N+1 Query Problem: How to Detect and Fix It

Practice4 min

In short

N+1 happens when code runs one query for a list and then one more query per item; you fix it by loading related data up front in one or two queries with eager loading, JOINs or batching.

What N+1 is and why it hurts

N+1 is when an application runs 1 query to fetch a list of records and then N separate queries, one per record, to load related data.

Example: a page with 100 posts, each showing its author. The code fetches posts in one query, then loops and fetches the author for each post. That is 101 queries instead of 1 or 2.

On a local database with ten rows you will not notice. In production, where the database lives on another server and every query is a network round-trip, the page slows down and database load grows with the size of the list.

How ORMs create N+1 silently

ORMs make relations look like ordinary object fields. But touching such a field can run a query.

Django:

posts = Post.objects.all()[:100]
for post in posts:
    print(post.author.name)  # a database query on every iteration

Laravel Eloquent:

$posts = Post::take(100)->get();
foreach ($posts as $post) {
    echo $post->author->name; // lazy loading: one query per post
}

Prisma and TypeORM do not load relations on access, so N+1 there tends to be explicit: a query written inside a loop or inside Promise.all over a list:

const posts = await prisma.post.findMany({ take: 100 });
const result = await Promise.all(
  posts.map((p) => prisma.user.findUnique({ where: { id: p.authorId } }))
);

GraphQL is a separate source of N+1: the author field resolver is called once per post.

How to detect N+1

  • SQL logs. Turn on query logging in development. The sign of N+1 is a run of identical queries that differ only in the id parameter.
  • APM and tracing. In a request trace, N+1 looks like a "staircase" of dozens of identical short database spans inside one HTTP request.
  • Framework tools. Django Debug Toolbar shows the query count and duplicates. In Laravel you can enable Model::preventLazyLoading() in development so lazy loading throws an exception.
  • Query count tests. For example, assertNumQueries in Django: the test fails if an endpoint starts running more queries than expected.

How to fix it

Eager loading

Tell the ORM up front which relations you need:

ORMFix
Djangoselect_related("author") for ForeignKey (JOIN), prefetch_related("tags") for M2M and reverse relations (separate IN query)
Laravel EloquentPost::with('author')->take(100)->get()
Prismaprisma.post.findMany({ take: 100, include: { author: true } })
TypeORMfind({ relations: { author: true } }) or leftJoinAndSelect in QueryBuilder

Before: 1 query for posts + 100 for authors = 101. After: 2 queries (posts + authors via IN) or 1 query with a JOIN.

A manual JOIN

If you only need a couple of fields, one query is simpler:

SELECT p.id, p.title, u.name AS author_name
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 100;

Batching and DataLoader

When relations are loaded from different places in the code (GraphQL resolvers, services), use DataLoader: it collects all ids requested within one tick and runs one WHERE id IN (...) query.

const userLoader = new DataLoader(async (ids: readonly number[]) => {
  const users = await prisma.user.findMany({ where: { id: { in: [...ids] } } });
  const byId = new Map(users.map((u) => [u.id, u]));
  return ids.map((id) => byId.get(id) ?? null);
});

Important: DataLoader must return results in the same order as ids. Create a new instance per incoming request so the cache never mixes data between users.

Common mistakes when fixing it

  • Loading everything. An include five levels deep pulls unnecessary data, which is a different performance problem.
  • JOIN on one-to-many with pagination. Rows get duplicated and LIMIT cuts result rows, not posts. For such relations a separate IN query works better.
  • Fixing one endpoint and forgetting the rest. Without query count tests, N+1 comes back with the next change.

FAQ

For a list of three items on a rarely used admin page, the difference is invisible. It becomes a problem when N grows with the data or the page is hit often. The habit of loading relations up front costs almost nothing.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.