awesome-everything RU
↑ Back to the climb

Performance

N+1: one logical operation, many round-trips

Crux One screen renders into 50 database queries because the ORM loads each related row on demand. The fix is not a faster query — it is fewer queries.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 10 min

A page is slow at 800 ms. The CPU profile looks fine — no wide functions. Sven opens the database query log and counts 51 queries for one page load. The profiler never lied; the bottleneck is not in any single function. It is spread across 51 small round-trips.

What the N+1 problem is

The user requests “show me a list of orders with their customer names.” The ORM executes one query for orders, then one query per order to fetch the customer — 1 + N queries. With 50 orders on the page that is 51 database round-trips for one render.

Each round-trip pays network latency (1–5 ms intra-DC, 10–50 ms cross-region) plus query overhead. A page that should take 20 ms takes 500 ms — and the engineer cannot find the problem in a CPU profile, because the cost is distributed across many fast calls. No single function is slow.

Why ORMs produce N+1 by default

Object-relational mappers expose a model where each object has relationships accessed like plain properties. order.customer reads as if it is a plain attribute; under the hood the ORM triggers a query if the customer is not yet loaded. This is called lazy loading — convenient because you do not declare what you need up front, but it is the source of N+1.

Loop over 50 orders, read order.customer.name on each one, and the ORM fires 50 queries without you writing 50 queries.

The pantry metaphor

Cooking pasta and need garlic, oil, salt, and basil:

  • Option A (batch): walk to the pantry once, grab all four, walk back. One trip.
  • Option B (N+1): walk for garlic, return; walk for oil, return; walk for salt, return; walk for basil, return. Four trips.

Same work — four trips versus one. The N+1 pattern is option B. The fix is option A: reduce the walks.

The unit of cost here is the round-trip: the time from when your code asks for data to when the data is in your hands. RTT, query parse, planner overhead, lock acquisition, result encoding — all paid per round-trip. One query of 100 rows pays it once; 100 queries pay it 100 times.

A concrete example

// ORM code that looks innocent:
const orders = await Order.findAll({ where: { userId } });
for (const order of orders) {
  const customer = await order.getCustomer(); // fires one query per order
  render(order, customer);
}

// Query log:
// SELECT * FROM orders WHERE user_id = 42 LIMIT 50      — 1 query
// SELECT * FROM customers WHERE id = 1                   — 1 query
// SELECT * FROM customers WHERE id = 2                   — 1 query
// ...
// SELECT * FROM customers WHERE id = 50                  — 1 query
// Total: 51 queries × ~15 ms RTT = 765 ms

The fix in Prisma or most ORMs is one word — include:

const orders = await prisma.order.findMany({
  where: { userId },
  include: { customer: true },
});
// Now: 2 queries — one for orders, one for all customers via IN (...)
// Total: ~20 ms
ApproachQueriesTime (intra-DC, 15 ms RTT)
Lazy (N+1)51~765 ms
Eager / include2~30 ms

Nested N+1: the problem compounds

A dashboard renders teams → projects → members. Naive ORM:

  • 1 query for teams
  • N queries for projects (one per team)
  • N×M queries for members (one per project)

With 10 teams × 5 projects × 8 members: 1 + 10 + 50 = 61 queries. Fix with nested eager loading: 3 queries total. Render time drops from 1.5 s to 80 ms.

Why this works

The N+1 name reflects the worst case: 1 query for the parent collection plus 1 per parent = 1 + N. The term entered mainstream programming around 2003–2005 with the rise of ORMs like Active Record (Rails 2004) and Hibernate (Java). The shape reappears in every new data-access framework: Prisma added include in 2020; every modern ORM ships it.

Quiz

A page is slow but the CPU profile shows no wide functions. The database query log shows hundreds of small queries. What is the most likely cause?

Quiz

Why does fetching 100 rows in ONE query beat fetching 100 rows in 100 separate queries?

Order the steps

Order the four common fix patterns for N+1, from simplest to most flexible:

  1. 1 JOIN — fetch parent and child in one query with a SQL join
  2. 2 IN (...) — first query gets parent IDs, second fetches all children WHERE child_id IN (parents)
  3. 3 Eager loading / preload — ORM directive that turns lazy access into one batched query
  4. 4 Batch loader (DataLoader) — collect all child IDs across the request, fire one batched query when the event loop yields
Complete the analogy

Fill in the blank: an N+1 query pattern is N+1 _______ — each one fast on its own, but the total of all the trips is what makes the page slow.

Recall before you leave
  1. 01
    In one paragraph: explain why the N+1 problem is hard to find with a CPU profile and where to look instead.
  2. 02
    What is lazy loading and why does it produce N+1 by default?
Recap

The N+1 problem turns one logical operation into 1 + N database round-trips by loading each related row lazily. With 50 items, that is 51 queries; at 15 ms RTT each, the page takes 765 ms instead of 30 ms. The root cause is lazy loading — the ORM default that fires a query each time a related property is accessed. The fix is structural: use an eager-load directive (Rails .includes, Django .select_related, SQLAlchemy selectinload, Prisma include) to tell the ORM what you need up front. A CPU profile will not show you this bottleneck; the query log will.

Connected lessons
appears again in159
Continue the climb ↑Fix families: JOIN, IN, preload, and DataLoader
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.