What you'll learn
Quick Answer
The N+1 problem happens when you fetch a list of N records with one query, then run a separate query for each record's related data — N extra queries you didn't intend to run. It's almost always caused by an ORM's lazy-loaded relation being accessed inside a loop, and it gets worse as your data grows because the query count scales with row count, not with code complexity. The fix is to fetch the related data in a single additional query, typically a JOIN or an explicit eager-load call, instead of one query per row.
What N+1 actually looks like
Take a simple case: 5 customers, each with 3 orders. You want each customer's orders. The naive approach fetches the customers, then loops over them and fetches each one's orders separately:
const customers = db.prepare('SELECT id, name FROM customers').all();
// customers.length === 5
const results = customers.map((c) => {
const orders = db
.prepare('SELECT id, total FROM orders WHERE customer_id = ?')
.all(c.id);
return { ...c, orders };
});Counting every query this code actually runs against a real SQLite database confirms it:
N+1 version query count: 6 (expected 1 + 5 = 6)One query for the customer list, plus one more for every single customer's orders — 1 + 5 = 6. With 5 rows this is barely noticeable. With 5,000 rows it is 5,001 round trips to the database for a page that should have needed one or two. Nothing about this code looks wrong when you read it; it reads like an ordinary loop over an array. That is exactly what makes N+1 the most common accidental performance bug in database-backed applications.
Why ORMs make this so easy to write by accident
Nobody sits down and deliberately writes a query inside a loop. It happens because ORMs make related data look like a normal object property, and lazily fetch it the moment you touch it:
// Looks completely innocent -- nothing here mentions SQL
const customers = await Customer.findAll();
const summary = customers.map((c) => ({
name: c.name,
orderCount: c.getOrders().length, // <-- fires one query, per customer
}));Nothing in that code mentions SQL, a query, or a database round trip. c.getOrders() reads like accessing an in-memory array. In Sequelize it is a lazy association method; in Django, touching order.customer on a queryset without select_related does the same thing; in Rails' ActiveRecord, calling .orders on an unloaded association fires a query the instant you call it. The abstraction that makes ORMs pleasant to use — letting you treat related rows like regular object properties — is the same abstraction that hides exactly when a query fires.
This is also why N+1 bugs survive code review so often: reviewers are reading application logic, not counting database round trips, and the code is, in every other respect, perfectly reasonable-looking.
The fix: fetch it in one query
The general fix is called eager loading: tell the ORM up front that you want the related rows, so it fetches everything in one extra query (or a JOIN) instead of one query per row. In raw SQL, that is a JOIN:
const rows = db.prepare(`
SELECT c.id AS customer_id, c.name, o.id AS order_id, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.id
`).all();
const byCustomer = new Map();
for (const row of rows) {
if (!byCustomer.has(row.customer_id)) {
byCustomer.set(row.customer_id, { id: row.customer_id, name: row.name, orders: [] });
}
if (row.order_id != null) {
byCustomer.get(row.customer_id).orders.push({ id: row.order_id, total: row.total });
}
}
const results = [...byCustomer.values()];Run against the same data, this produces:
JOIN version query count: 1
Row count returned by JOIN (fan-out): 15 -- one row per order, not per customerOne query total, down from six — and it returns exactly the same nested data structure as the N+1 version once grouped in application code. Most ORMs give you a one-line way to ask for this instead of hand-writing the JOIN:
// Sequelize: eager-load orders in the same query
const customers = await Customer.findAll({ include: Order });
// Django: same idea, opposite direction of the N+1
Customer.objects.prefetch_related('orders').all()The rule of thumb: if you know before the loop starts that you'll need the related data for every row, ask for it before the loop, not inside it.
The JOIN gotcha: fan-out rows
Notice the second line of output above: the JOIN returned 15 rows for 5 customers, not 5. A LEFT JOIN against a one-to-many relationship produces one row per matching child — 5 customers × 3 orders each = 15 rows on the wire, which your application code then has to group back into 5 logical customers.
This trips people up in two specific ways. First, if you forget the grouping step and just count rows, rows.length tells you there are 15 customers when there are 5 — a classic source of a dashboard reporting the wrong count right after someone optimized an N+1 query into a JOIN. Second, if a customer has zero orders, a LEFT JOIN still returns one row for them with every order column NULL — drop the LEFT for a plain JOIN and that customer disappears from the results entirely, which is a much harder bug to notice than a wrong count.
Fixing N+1 with a JOIN is correct, but it trades one problem (too many queries) for a different one (denormalized rows that need re-grouping) — it doesn't remove the need to think about the shape of your data.
How to catch N+1 before your users do
N+1 bugs are almost invisible in local development and obvious in production, because the effect scales with row count. A test database with 3 sample customers hides the bug completely — 4 queries feels fine. The same code against 50,000 real customers turns one page load into 50,001 round trips, and that only shows up once real data volume exists, which is often after launch.
The practical defenses: turn on query logging in development (Sequelize's logging: console.log, Django Debug Toolbar's query panel, Prisma's query events) and actually look at the count for a page that lists related records, not just whether it works. Tools built specifically for this, like Rails' Bullet gem, watch for the lazy-load-inside-a-loop pattern and warn you at request time. And when load-testing, seed your test database with production-realistic row counts — a query count that looks fine with 5 rows and terrible with 5,000 is the whole bug, so testing with 5 rows never catches it.
