50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
databaseMay 24, 2026·7 min read

N+1 Query Problems: How to Spot and Fix Them Before They Kill Your App

The N+1 problem silently destroys app performance. Here's how to catch it in your ORM logs, fix it with eager loading, and stop writing it in the first place.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

N+1 Query Problems: How to Spot and Fix Them Before They Kill Your App

We once deployed a feature on a Friday afternoon (yes, we know) and by Saturday morning the database CPU was sitting at 85% for a page that showed a list of 50 users. The page looked fine. Response times were 'only' around 800ms. Nobody complained yet. But when we checked the query logs, there were 51 database queries happening for a single page load. Classic N+1. We'd been so focused on getting the feature out that we didn't notice we'd written a query disaster.

N+1 is one of those bugs that doesn't crash your app — it just slowly makes it worse until one day you have 500 users and everything is inexplicably slow. Let's talk about what it actually is, how to catch it, and how to fix it regardless of whether you're using Prisma, Drizzle, or TypeORM.

What N+1 Actually Means

The name comes from the math. You make 1 query to get a list of N records, then you make N more queries to fetch related data for each record. Load 50 posts? That's 1 query for posts + 50 queries for each post's author = 51 queries. Load 200 orders? That's 201 queries. It scales linearly with your data, which is the worst possible scaling curve for a database.

The reason it happens so naturally is that ORMs make it easy. You fetch a list, you iterate over it, and inside the loop you access a relationship. The ORM lazily loads that relationship for each item. It feels clean. It reads like normal code. But every `.author`, `.category`, or `.tags` inside a loop is potentially a database round trip.

// This looks totally innocent. It is not.
const posts = await prisma.post.findMany();

for (const post of posts) {
  // Each of these triggers a separate SQL query
  const author = await prisma.user.findUnique({
    where: { id: post.authorId },
  });
  console.log(`${post.title} by ${author.name}`);
}

// If you have 100 posts, you just ran 101 queries.
// Congratulations, you've invented N+1.

In the above example it's obvious because the extra queries are explicit. The more dangerous version is when you're using an ORM's relationship accessors and the queries are hidden — like accessing `post.author` in a template when you didn't include the author in the original query.

How to Spot N+1 in the Wild

The first step is visibility. You can't fix what you can't see. Most ORMs have query logging you should have enabled in development from day one.

// Prisma — enable query logging in development
const prisma = new PrismaClient({
  log: process.env.NODE_ENV === 'development'
    ? ['query', 'info', 'warn', 'error']
    : ['error'],
});

// Now every SQL query prints to your console.
// If you see the same query repeated with different IDs,
// you've found your N+1.

With Drizzle you can pass a logger to the database connection:

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

const queryClient = postgres(process.env.DATABASE_URL!);

const db = drizzle(queryClient, {
  logger: process.env.NODE_ENV === 'development',
});

// Or with a custom logger for more control:
const db = drizzle(queryClient, {
  logger: {
    logQuery(query, params) {
      console.log('SQL:', query);
      console.log('Params:', params);
    },
  },
});

In development, open your terminal while clicking through your app. If you see the same query firing repeatedly with just the ID changing, that's your N+1. It's genuinely that obvious once you have logging on — which is why it's baffling that so many developers build entire apps without ever turning it on.

Enable query logging in development. Always. If looking at your query log gives you anxiety, that's the point — it means there's something to fix.

For production, tools like Sentry's performance monitoring, Datadog APM, or even just checking slow query logs in your Postgres instance (queries over 100ms) will surface N+1 issues that you missed in development.

The Fix: Eager Loading

The standard fix for N+1 is eager loading — telling the ORM to fetch related data upfront in the same query (or one additional JOIN query) instead of lazily per-record.

In Prisma, this is the `include` option:

// Before: N+1 waiting to happen
const posts = await prisma.post.findMany();
// + N queries when you access post.author somewhere

// After: 1 query with a JOIN
const posts = await prisma.post.findMany({
  include: {
    author: {
      select: {
        id: true,
        name: true,
        avatarUrl: true,
      },
    },
    tags: true,
    _count: {
      select: { comments: true },
    },
  },
});

// Now posts[0].author, posts[0].tags, posts[0]._count.comments
// are all available without any additional queries.

Drizzle handles this differently — it doesn't have implicit lazy loading, which is actually a feature. Everything is explicit. But you can still fall into N+1 territory if you're running queries inside loops. The correct pattern is to use `with` in your queries:

import { db } from '@/lib/db';
import { posts, users, tags } from '@/lib/schema';

// Drizzle with relational queries (needs schema relations defined)
const postsWithAuthors = await db.query.posts.findMany({
  with: {
    author: {
      columns: {
        id: true,
        name: true,
        avatarUrl: true,
      },
    },
    tags: true,
  },
  where: (posts, { eq }) => eq(posts.published, true),
  limit: 20,
});

// Or with explicit joins if you need more control:
const result = await db
  .select({
    postId: posts.id,
    postTitle: posts.title,
    authorName: users.name,
  })
  .from(posts)
  .leftJoin(users, eq(posts.authorId, users.id))
  .where(eq(posts.published, true))
  .limit(20);

When Eager Loading Isn't Enough: The Batching Approach

Sometimes you can't easily join everything in one query. Maybe the data comes from different places, or the query gets too complex. In these cases, batching is the answer: collect all the IDs you need, make one query with `WHERE id IN (...)`, then map the results back.

// Scenario: you have posts, and you need to fetch author details
// but authors might come from different sources

const posts = await db.query.posts.findMany({
  where: (posts, { eq }) => eq(posts.published, true),
});

// Collect all unique author IDs (no duplicates)
const authorIds = [...new Set(posts.map(p => p.authorId))];

// Fetch all authors in ONE query
const authors = await db.query.users.findMany({
  where: (users, { inArray }) => inArray(users.id, authorIds),
  columns: { id: true, name: true, avatarUrl: true },
});

// Create a lookup map for O(1) access
const authorMap = new Map(authors.map(a => [a.id, a]));

// Combine without any additional queries
const postsWithAuthors = posts.map(post => ({
  ...post,
  author: authorMap.get(post.authorId),
}));

// Total queries: 2, regardless of how many posts you have.

This pattern — collect IDs, batch fetch, build a Map, join in memory — is one of the most useful patterns you'll use in backend development. Two queries for any amount of data. It's essentially what DataLoader (popularized by GraphQL) does under the hood.

N+1 in React Server Components: The New Trap

With Next.js App Router, there's a newer form of N+1 that's easy to miss. When you have a list component that renders child components, and each child component fetches its own data, you can end up with the same waterfall problem at the component level.

// posts/page.tsx — this pattern is asking for trouble
async function PostCard({ postId }: { postId: string }) {
  // Each PostCard fires its own query. For 20 posts, that's 20 queries.
  const post = await db.query.posts.findFirst({
    where: (posts, { eq }) => eq(posts.id, postId),
    with: { author: true },
  });

  return <div>{post?.title} by {post?.author.name}</div>;
}

export default async function PostsPage() {
  const postIds = await db.query.posts.findMany({
    columns: { id: true },
  });

  return (
    <div>
      {postIds.map(({ id }) => (
        <PostCard key={id} postId={id} />
      ))}
    </div>
  );
}

// Better approach: fetch everything at the page level
export default async function PostsPage() {
  const posts = await db.query.posts.findMany({
    with: { author: true },
    limit: 20,
  });

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

The rule here is: fetch data as high up in the component tree as makes sense, then pass it down. Don't let each leaf component fetch its own data from a list — that's the component-level N+1.

There are cases where you want each component to fetch its own data (for streaming, for example), but then use `Promise.all` or Suspense boundaries thoughtfully so queries run in parallel rather than sequentially.

Preventative Measures: Stop Writing N+1 in the First Place

Fixing N+1 after the fact is fine, but not writing it is better. Here's what actually helps:

  • Always have query logging enabled in development. Make it the default in your dev setup, not something you turn on when debugging.
  • Review your queries before merging. Not every PR, but any time you're adding a new page or feature that fetches lists of data.
  • Be suspicious of any ORM call inside a loop. If you see `.findUnique` or `.findFirst` inside a `.map()` or `for...of`, stop and think.
  • Use `select` or `columns` to only fetch what you need — this also makes N+1 more obvious because you're being explicit about your data requirements.
  • Consider using a query counter in your integration tests: if a test page load fires more than X queries, fail the test. Libraries like `prisma-query-counter` exist for exactly this.
  • With Drizzle specifically: embrace the explicitness. If you have to explicitly write the join, you'll think about it. This is a feature, not a limitation.
The best N+1 is the one you never wrote. The second best is the one you caught in code review rather than production at 2am.

We've built Peal's templates with these patterns baked in from the start — every list query uses proper includes or joins, and the Drizzle setup has logging enabled by default in dev. It's one of those things that's much easier to get right at the template level than to retrofit later.

One More Thing: Don't Over-Eager-Load Either

We should mention the opposite mistake, because it's also real. You fix your N+1 by including everything, and now you're pulling back 50 fields per record when you only need 3. A query that fetches users with all their orders, all order items, all product details, and all shipping addresses for a dropdown that shows just the user's name is not better than N+1 — it's just a different kind of slow.

Always pair `include`/`with` with `select`/`columns` to limit what you actually fetch. Only pull the columns you render. This keeps your queries fast and your response payloads small.

// Too much: fetching everything when you need almost nothing
const users = await prisma.user.findMany({
  include: {
    orders: {
      include: {
        items: {
          include: { product: true },
        },
      },
    },
  },
});

// Just right: fetch exactly what the UI needs
const users = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    email: true,
    orders: {
      select: {
        id: true,
        total: true,
        createdAt: true,
        _count: { select: { items: true } },
      },
      orderBy: { createdAt: 'desc' },
      take: 5, // last 5 orders only
    },
  },
});

The mental model: N+1 is too many queries. Over-fetching is too much data per query. You're trying to find the middle ground — the minimum number of queries that fetch exactly the data you need.

Turn on query logging today. Click through your app in dev and watch the console. If you see the same query firing 20 times with different IDs, you've got work to do — and now you know exactly how to do it.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders