Yashveer Singh
Connect
<- All posts
Backend, APIs, and System Design12 min read

The N+1 Query Problem: Detection, Prevention, and Refactoring

The N+1 query problem occurs when an application executes one query to retrieve a list of N records, then executes N additional queries to retrieve related data for each record -- producing N+1 total queries where one query with a join would have sufficed. At small scale, the performance impact is invisible. At production scale, N+1 queries are one of the most common causes of slow API endpoints and database overload. Detection requires query logging; prevention requires joins or data loaders; refactoring requires identifying which ORM calls produce multiple queries and replacing them with eager loading.

Written by Yashveer Singh, founder of Yashveer Labs.

What you actually need to know

  • The N+1 query problem is invisible in development (small data) and catastrophic in production (large data). An endpoint that fires 1 query in development fires 1,001 queries in production with a list of 1,000 records.
  • Detection requires query logging. Without counting queries per request, N+1 problems accumulate silently until they cause production incidents.
  • The fix is almost always a JOIN or a batched second query. JOINs are the simpler fix for straightforward relationships. Batched queries (fetching all related records at once and joining in application code) are the fix for complex cases or when ORM join syntax is cumbersome.
  • DataLoader is the N+1 solution for GraphQL. In REST APIs, eager loading (explicit JOINs or batched queries) is the standard fix.
  • Fixing N+1 problems is one of the highest-return performance optimizations available -- often reducing endpoint response times by 10x-100x with no infrastructure changes.
ScenarioQuery CountFix
Load 10 posts1--
Load 10 posts + authors (N+1)11JOIN or batch
Load 100 posts + authors (N+1)101JOIN or batch
Load 100 posts + authors (fixed)1-2JOIN or 2 queries
Load 100 posts + authors + comments (nested N+1)Up to 10,001Multiple JOINs or 3 batched queries

The core argument

The N+1 query problem is one of the most pervasive and most misunderstood performance problems in web application development. It is misunderstood because it is invisible at the code level -- the application code looks correct, the individual queries are fast, and the endpoint works in development. The problem only appears at scale, when the list contains enough records that the accumulated overhead of N individual queries exceeds what the database connection pool can handle.

At 10 records, the N+1 problem is invisible. At 100 records, it produces mildly slow endpoints. At 1,000 records, it produces timeouts. At 10,000 records, it takes down the database. The performance curve is linear with the list size, which means the problem that was invisible in development becomes catastrophic at a specific threshold in production -- usually without warning, and usually under the load of a customer who is using the product seriously.

The fix is not complex -- it is a JOIN or a batched second query -- but it requires understanding why the queries are being fired in the first place, which requires query logging.

How N+1 queries form

The classic N+1 pattern in a blog application:

```typescript // N+1 pattern -- fires 1 + N queries const posts = await db.select().from(postsTable); // One query: SELECT * FROM posts -- returns 100 posts

for (const post of posts) { const author = await db.select() .from(usersTable) .where(eq(usersTable.id, post.authorId)) .limit(1); // One query per post: SELECT * FROM users WHERE id = $1 // 100 queries for 100 posts } // Total: 101 queries ```

Each individual query is fast (single-row lookup by primary key), so the problem is invisible in development. In production with 1,000 posts, this endpoint fires 1,001 queries.

The N+1 pattern is most common in:

  • ORM relationship access (post.author in Sequelize or TypeORM with lazy loading enabled)
  • Nested GraphQL resolvers (each resolver fetches its own data independently)
  • Template rendering loops that query the database per item
  • Background jobs that process a list of records and query per record

Detection: query logging in development

The first step is making queries visible. In a Node.js application with postgres.js or pg:

``typescript // Log all queries in development const db = drizzle(client, { logger: process.env.NODE_ENV === 'development' ? { logQuery(query, params) { console.log(Query: ${query}, params); } } : false }); ``

With Prisma: ``typescript const prisma = new PrismaClient({ log: ['query'], }); ``

Enable query logging and load a list page in the application. Count the queries. If the count grows with the list size, N+1 is present.

In production, use a database monitoring tool that shows per-endpoint query counts. Sentry Performance shows database spans per transaction. Datadog APM shows queries per endpoint. The signal is an endpoint where the query count is proportional to the response size.

Fix one: the JOIN

The JOIN retrieves all related data in a single query by combining tables in the database:

``typescript // Fixed with a JOIN -- fires 1 query const postsWithAuthors = await db .select({ id: postsTable.id, title: postsTable.title, content: postsTable.content, author: { id: usersTable.id, name: usersTable.name, email: usersTable.email, } }) .from(postsTable) .leftJoin(usersTable, eq(usersTable.id, postsTable.authorId)); // Total: 1 query ``

The JOIN approach is the simplest fix and the most efficient -- the database optimizes the join, and only one round-trip to the database occurs.

Use the JOIN when:

  • The relationship is simple (one-to-one or many-to-one)
  • The related data fields are known in advance
  • The ORM supports the join syntax cleanly

The limitation: deeply nested relationships (posts with authors with organizations with teams) produce complex JOINs that are harder to read and can produce duplicate rows that require deduplication. For nested relationships beyond two levels, the batched query approach is often cleaner.

Fix two: the batched second query

The batched approach uses two queries instead of one per record:

```typescript // Fixed with a batched second query -- fires 2 queries total const posts = await db.select().from(postsTable); // Query 1: SELECT * FROM posts -- returns 100 posts

const authorIds = posts.map(post => post.authorId); const authors = await db .select() .from(usersTable) .where(inArray(usersTable.id, authorIds)); // Query 2: SELECT * FROM users WHERE id IN ($1, $2, ..., $100)

// Join in application code const authorMap = new Map(authors.map(a => [a.id, a])); const postsWithAuthors = posts.map(post => ({ ...post, author: authorMap.get(post.authorId) })); // Total: 2 queries ```

The batched approach is two queries instead of N+1. It is slightly less efficient than a JOIN (two round-trips to the database) but is often easier to read and compose for complex relationships.

Use the batched approach when:

  • The relationship is one-to-many (one post has many comments -- the JOIN produces duplicate post rows)
  • The related data comes from a different table that is complex to join
  • The ORM's join syntax is cumbersome for the specific case

Fix three: DataLoader for GraphQL

In GraphQL APIs, N+1 problems appear in resolvers because each resolver fetches its own data independently. DataLoader batches these fetches within a single request tick:

```typescript import DataLoader from 'dataloader';

// Create a loader that batches user fetches const userLoader = new DataLoader(async (userIds: readonly string[]) => { const users = await db .select() .from(usersTable) .where(inArray(usersTable.id, [...userIds]));

// Return users in the same order as the input IDs const userMap = new Map(users.map(u => [u.id, u])); return userIds.map(id => userMap.get(id) ?? null); });

// In a GraphQL resolver: const Post = { author: (post) => userLoader.load(post.authorId) // Instead of: db.select().from(users).where(eq(users.id, post.authorId)) }; ```

When 100 posts are resolved, 100 userLoader.load() calls are made -- but DataLoader collects them all within one tick and fires a single batched query for all 100 user IDs. The resolver code looks like it is making 100 queries; the database sees one.

Common mistakes teams make with N+1 queries

  1. Not enabling query logging in development. The N+1 problem is invisible without it. Query logging should be on by default in local development environments, not added after a production incident.
  2. Fixing N+1 in the wrong layer. Adding caching on top of an N+1 pattern reduces the database load but does not fix the underlying problem. Cache misses still fire N+1 queries, and cache invalidation adds complexity. Fix the query pattern first.
  3. Over-fetching related data as the fix. A JOIN that fetches all columns from the related table when only one or two are needed produces unnecessarily large payloads. Select only the columns needed.
  4. Not testing with production-like data volumes. An endpoint that handles 20 records in development and 2,000 records in production will not reveal the N+1 problem in development. Load tests with realistic data sizes should be part of the pre-launch checklist.
  5. Fixing visible N+1 problems while ignoring nested ones. A list of posts with eager-loaded authors that then lazy-loads each author's organization is still an N+1 at the organization level. Fix the entire chain.

Where to start: a 3-step N+1 audit

Step 1: Enable query logging for every endpoint in the development environment. Instrument every request to log the query count. Any endpoint with a query count that grows with the list size is an N+1 candidate.

Step 2: Prioritize by frequency and list size. An endpoint that is called 10,000 times per day and returns 100 records per call with N+1 queries fires 1,010,000 queries per day. Fix this before fixing an endpoint called 10 times per day. Sort the N+1 candidates by (call frequency × average list size) and fix the top of the list first.

Step 3: Apply the JOIN fix for simple one-to-one and many-to-one relationships, the batched query fix for one-to-many relationships, and DataLoader for GraphQL resolvers. After applying each fix, verify the query count with logging and confirm the endpoint response time has improved.

The Endpoint That Scaled Without a New Server

Yashveer Singh. Founder of Yashveer Labs. A client's course listing endpoint was timing out in production for users with more than 200 enrolled courses. The investigation took twenty minutes: enabling query logging revealed the endpoint was firing one query to get the enrolled course list, then one query per course to get the instructor, and one query per course to get the enrollment status. For a user with 200 enrolled courses, this was 601 queries. The fix was a single rewrite: one query with two LEFT JOINs that retrieved the course list, instructor data, and enrollment status in a single database round-trip. The endpoint went from timing out (5+ seconds) to 80ms. No infrastructure changes, no new servers, no caching layer. The problem was the query pattern. The fix was replacing the pattern.

Related reading

FAQ

Frequently asked

Author

Closing note from the author

I keep these closing notes short on purpose. Most engineers writing about this topic are not the engineer you want to hire. I might be. Yashveer Singh, founder of Yashveer Labs. The contact channel is Instagram. The proof is the portfolio. The standard is in the work. If we are aligned, you will know within five minutes of the first message.

Related reading