DevTools Hub

Search tools

Search for a developer tool

What Causes N+1 Queries

Part of the SQL Toolkit

An N+1 query problem is invisible to EXPLAIN ANALYZE, and that's exactly what makes it dangerous — there is no single slow query to find, because every individual query involved is completely fine, often fast, and completely correct on its own. The problem is entirely in how many of them get sent: one query to fetch a list of N things, then N more queries fetching something related to each one, one row at a time, when a single follow-up query could have done it in one round trip.

Where the name comes from

1 initial query, plus N follow-up queries — one per row the first query returned. Fetch 50 users, then loop over them fetching each one's orders individually, and that's 1 + 50 = 51 total queries for what should have been 2 at most.

The root cause: a query hiding behind what looks like a property access

N+1 almost never comes from someone deliberately writing a loop full of queries — it comes from an ORM's lazy loading making a network round trip look identical to reading a field that was already in memory:

const users = await User.findAll();       // 1 query
for (const user of users) {
  const orders = await user.getOrders();  // looks like a property read —
  console.log(orders.length);             // is actually 1 query, every single time
}

Nothing about that loop looks wrong when reading it — user.getOrders() reads like accessing data that's already there. The ORM is quietly issuing a fresh SELECT * FROM orders WHERE user_id = ? on every single iteration, and the abstraction that's supposed to make the database easier to work with is exactly what's hiding that it's happening at all.

How fast this actually gets worse

The query count scales directly with the size of the list, not with how much data actually needed fetching:

Users in the listTotal queries (1 + N)
1011
100101
1,0001,001

Each of those extra queries pays real, fixed per-round-trip overhead on top of whatever the query itself costs — network latency, connection/protocol overhead, query planning — typically on the order of a millisecond or more even for a trivially fast query. At 1,000 rows, a page that should've cost one connection's worth of round-trip time now pays that cost a thousand times over, purely from the shape of the request — before counting a single millisecond of actual query execution.

Fix 1: eager loading — with a real caveat

The straightforward fix is telling the ORM to fetch the related data up front, in the same query (a JOIN) or immediately after (a second batched query) — every major ORM exposes this as an explicit "eager load" or "include" option precisely because lazy loading is the default that causes this in the first place.

The caveat: joining multiple one-to-many relations onto the same query at once multiplies rows. A user with 5 orders and 3 reviews, joined onto both in a single query, doesn't return 8 rows — it returns 15 (every order paired with every review), because that's what a join does. Fine for one relation; for more than one, it turns into redundant data the application then has to de-duplicate and reassemble, sometimes costing more than the N+1 it replaced.

Fix 2: batch loading — a fixed number of queries, not one big join

The other real fix avoids that row-multiplication problem entirely: fetch the parent rows first, collect their IDs, then issue one follow-up query per related type using WHERE user_id IN (...) instead of a separate query per row:

const users = await User.findAll();                      // 1 query
const ids = users.map(u => u.id);
const orders = await Order.findAll({ where: { userId: ids } }); // 1 query, all users at once

Two queries total, regardless of whether the list has 10 users or 10,000 — and no row multiplication, since each relation gets its own separate batched query instead of being joined together. The DataLoader pattern (originally Facebook's dataloader library, now reimplemented across most ecosystems) automates exactly this: it collects every individual lookup requested during one request or tick and replaces them with a single batched, deduplicated query behind the scenes.

GraphQL: where N+1 is the structural default, not a mistake

REST/ORM code has to write the accidental loop that causes this. A naive GraphQL resolver doesn't — it's built into the shape of the API. Every field can have its own resolver function, and a resolver for "this post's author" that just looks up the author by ID runs once per post in a result list, independently, by design — a hundred posts means a hundred independent author lookups unless something batches them. This is the specific reason DataLoader exists in the first place: it sits inside the resolver, batching every author lookup requested across the whole request into one query, without the resolver itself needing to know any of the other posts exist.

Why this is invisible to a query planner

SQL Explain Helper and How Query Optimization Works both operate on a single query's execution plan — genuinely powerful for finding why one query is slow, and genuinely unable to see N+1 at all, because none of the N+1 individual queries is slow. The signal lives one layer up, in the request as a whole: an APM tool or query logger showing 101 near-identical queries fired for one page load is the actual symptom, not anything visible in any single query's plan.

Try it yourself

Common SQL Mistakes covers this alongside the other query-shape mistakes that produce correct results and bad performance. SQL Index Advisor and Database Index Cost Estimator help once the query shape itself is fixed and the remaining question is whether the follow-up query needs an index.

FAQ

Is N+1 a database problem or an application problem?

Application. Every individual query is valid, typically fast, and needs no index it doesn't already have — the problem is entirely the decision, made in application code (usually implicitly, by an ORM's default), to issue N separate round trips instead of one or two.

Can N+1 happen without an ORM?

Yes — any hand-written loop that queries once per iteration has the identical problem. An ORM just makes it far more likely to happen by accident, since user.getOrders() reading like a plain property access is exactly what hides the query underneath it.

Does eager loading always fix it for free?

Not automatically — see the row-multiplication caveat above. Eager-loading one relation is close to free; eager-loading several at once via a single join can trade N+1 network round trips for one query returning far more redundant rows than expected, which is exactly why batch loading (or DataLoader) is often the safer general-purpose fix.

Related tools