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 customer

One 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.

Frequently Asked Questions

What is the N+1 query problem, in one sentence? Running 1 query to fetch a list plus N more queries, one per row, to fetch each row's related data, when a single additional query could have fetched it all at once.
Does N+1 only happen in ORMs? It's most common in ORMs because lazy loading hides the query behind a normal-looking property access, but you can write the same bug in raw SQL by looping over results and querying inside the loop.
Is a JOIN always the right fix? Usually, but not always. A JOIN causes row fan-out for one-to-many relationships, which needs re-grouping in application code; for very wide relationships, a separate second query with a WHERE IN clause can be simpler than untangling a large JOIN.
How many queries is too many for a single page? There's no fixed number, but a query count that scales with the number of rows displayed (rather than staying constant) is the actual signal to look for, regardless of the raw total.
Can pagination hide an N+1 problem in testing? Yes. Paginating to 10 rows per page caps N at 10, so the bug looks harmless in every manual test, then reappears in full force the moment someone removes the page size limit or requests a bigger page.