skip to content

A service loads 200 order rows and then reads each order's customer name, and the database log shows 201 statements. Explain what is happening, why it is called the N+1 problem, and why it hurts more than the statement count suggests.

level: juniorimportance: must knowfreq 80%

answer

  1. 1 query for parents + N queries for children
  2. cheap statements, expensive round trips
  3. never trips the slow-query log
  4. N = result set size -> fails at scale, fine in dev
  5. repeated parents and cache hits mask the count

basics

~20 s

One query fetched the 200 orders; touching each order's lazy customer triggered one more query per order, so 1 + N = 201. The cost is dominated by 200 network round trips, not by the work each tiny query does.

solid answer

~50 s

The first statement is the query that returned N parent rows. Each parent holds a lazy association; the moment your loop dereferences it, the ORM must materialise that association and issues a SELECT for it. N parents, N extra selects, plus the original — **1 + N**. The reason it is a serious problem rather than a curiosity is **latency multiplication**. Each statement is a separate round trip: send, wait, parse, execute, return. Even at 0.5 ms per round trip, 200 of them is 100 ms of nearly pure waiting, and the same data could have come back in a single joined query in a couple of milliseconds. The database's own CPU cost is trivial — which is exactly why it does not show up in slow-query logs and why the DBA sees nothing wrong. It also scales with the result set, so it degrades exactly when the system is busiest: a page that is fine with 10 rows collapses at 1,000.

code

java · 8 lines
java
List<Order> orders = em.createQuery(
        "select o from Order o where o.status = :s", Order.class)
    .setParameter("s", Status.OPEN)
    .getResultList();                 // 1 statement

for (Order o : orders) {
    audit(o.getCustomer().getName()); // 1 statement per order
}

go deeper

for a junior

Show the loop, count 1 + N, and say the fix is to fetch the associated data in the original query.

for a middle

Add why it hides from the database's own monitoring (round-trip cost, not execution cost) and how the persistence context can mask the count when parents repeat.

for a senior

Frame it in terms of latency budget, connection-pool occupancy under concurrency, and the fact that it degrades with data growth rather than failing outright.

for a principal

Treat it as an architectural default: fetch plans belong to use cases, and the system needs a mechanism that makes an unbounded per-row query pattern visible before it ships.

## The shape of the problem N+1 is the situation where retrieving a collection of N objects plus one related piece of data for each of them costs **N + 1** database statements instead of one or two. The canonical form: ```java List<Order> orders = em.createQuery("select o from Order o", Order.class) .getResultList(); // statement 1 for (Order o : orders) { print(o.getCustomer().getName()); // statements 2 .. N+1 } ``` The first statement returns the orders. Each `getCustomer()` returns an uninitialised proxy holding only the customer id; calling `getName()` forces the ORM to load that row, so it emits `select ... from customer where id = ?`. Two hundred orders, two hundred extra selects. The same shape appears with collections: ```java for (Order o : orders) { total += o.getLines().size(); // one SELECT per order } ``` ## Why it is invisible in the usual places Each individual statement is a primary-key lookup returning one row. It runs in microseconds, uses an index perfectly, and never crosses a slow-query threshold. The database's CPU graph is flat. Query plans are optimal. Everything the DBA looks at says the system is healthy. The damage lives on the **application side**, in the count of round trips. Every statement costs: - serialisation of the statement and parameters, - a network hop to the database and back, - connection/statement handling in the driver and pool, - ORM work to build the result and register the entity in the persistence context. At 0.5 ms of round-trip latency — a normal number for a database in the same datacentre — 200 statements is ~100 ms. Over a network link with 2 ms latency, it is 400 ms. A single join returning the same data is one round trip. ## Why it scales the wrong way N is the size of the result set, so cost grows linearly with data volume and with page size. It is the classic "worked in dev, died in prod" defect: the dev database has 20 orders and the production one has 20,000. It also compounds under concurrency — each request holds its connection for the whole burst of statements, so pool exhaustion arrives well before the database is actually busy. Two N+1s nested inside each other (parents, then children, then grandchildren) multiply. ## Where the extra statements come from There are two distinct origins worth naming: 1. **Lazy associations dereferenced in a loop** — the example above. The mapping is correct; the *query* did not fetch what the code went on to use. 2. **Eagerly-mapped to-one associations in a query result** — the ORM must satisfy the eager contract, and for a JPQL/HQL result list it typically does so with a secondary select per row rather than by rewriting your query into a join. Here the loop is not even necessary; simply returning the list triggers it. Both are the same defect from the database's point of view: the fetch plan for this use case was never expressed. ## What partially masks it - **Repeated parents.** If 200 orders belong to 5 customers, only 5 selects run — the persistence context returns the already-loaded instance for the rest. So the observed count depends on data distribution, and a test fixture with one shared customer hides the problem completely. - **Caching.** A second-level cache hit avoids the statement entirely, so an N+1 can be invisible on a warm system and brutal after a deploy. ## How it is fixed, in outline The correct fix is to declare the data the use case needs at query time — a fetch join or an entity graph — or to project only the columns you need into a DTO so associations never enter the picture. Batching strategies reduce N+1 to N/batch + 1 without changing the query. What all of these have in common is that the *query*, not the mapping, expresses the use case. ## The one-sentence version "One query for the parents, then one more per parent because the data the loop needed was not in the original fetch — cheap statements, expensive round trips, and it gets worse as the table grows."

  • If every one of those extra queries is a fast indexed primary-key lookup, why is it a problem at all?
    Because the cost is not in executing the statement but in issuing it: each one is an independent round trip with driver, network and ORM overhead, and those add up linearly. Two hundred half-millisecond round trips is a hundred milliseconds of latency the user pays, while the database reports no slow queries at all. It also holds a pooled connection far longer than necessary, so throughput collapses before the database itself is under load.
  • Why might the same code produce 201 statements in production and only 6 in your test?
    Because N+1 counts distinct associated rows that are not already in the persistence context. If a test fixture points every order at the same handful of customers, the first load of each customer is cached in the context and the rest are served without SQL. Production data is far more diverse, so nearly every parent triggers its own select. Second-level cache hits can hide it further on a warm system.

Sending a courier to the warehouse for one item at a time. Each trip is quick and nothing is wrong with the warehouse — but two hundred trips take all afternoon, when one trip with a list would have done.

saying these in an interview costs you the question

  • Calling it a database performance problem when the database is doing nothing slow.
  • Claiming the fix is to make associations EAGER — that usually causes N+1 on every query rather than removing it.
  • Believing the slow-query log would have caught it.
  • Ignoring that N is the result-set size, so the defect scales with data growth.
  • Saying the extra queries are 'just cache misses' and will disappear on their own.

context