A web app loads a list of 50 orders, then in a template loop calls order.getLineItems() for each one, and the ORM fires 50 separate SELECT statements plus the original query. What is this problem called, why does lazy loading cause it, and how would you fix it without simply switching every association to eager loading?
answer
- N parent rows + 1 extra query each
- caused by touching a lazy assoc in a loop
- fix with a fetch join, not global eager
- batch/subselect fetch collapses to ~2 queries
- invisible with small test data, deadly at scale
basics
~20 sThis is the N+1 selects problem: one query loads the list, then one extra query per item fetches its related data, because each lazy association is fetched separately the moment it's touched. Fix it by fetching the related data in one batched query up front, not by making everything eager everywhere.
solid answer
~50 sThis is the classic N+1 selects problem: 1 query loads N parent rows, and because each order's line-items association is lazily loaded, touching order.getLineItems() inside the loop triggers a separate SELECT per order - N additional queries, N+1 total. It happens because lazy loading defers fetching an association until it's actually accessed, and a naive loop accesses it once per parent with no batching context. The fix is not blanket eager loading, which would over-fetch on every other code path that only needs the order header; instead, fetch line items for this specific use case in one shot, e.g. a JOIN FETCH / eager fetch join scoped to that one query, a batch-fetch/subselect strategy that loads all line items for the visible orders in a single IN (...) query, or a dedicated projection/DTO query that only pulls the columns the screen needs. The general principle is to choose the fetch strategy per query/use case rather than per association globally.
go deeper
Should recognize that looping and calling a getter per row can trigger one query per row, and be able to name the problem.
Should explain why lazy loading causes it and describe at least one concrete fix (fetch join or batch fetching) with its trade-off.
Should compare fetch-join, batch/subselect fetching, and DTO projections, and know how to detect the problem via query logging or count assertions before it reaches production.
Should reason about fetch strategy as a per-use-case architectural decision - e.g. designing read models/projections for high-traffic endpoints separately from the full domain graph used for writes.
## Where the name comes from The N+1 selects problem gets its name from the query count: one query retrieves the N parent rows (here, 50 orders), and then, because the association from order to its line items is lazily loaded, each of the N orders triggers its own additional query to fetch its line items when the code accesses that association - N extra queries, for a total of N+1 round trips to the database. In the scenario described, that's 51 SQL statements to render one page, where a well-designed query could have done the same job in one or two. ## Why lazy loading produces it This happens because of how Lazy Load works: rather than fetching an entire object graph eagerly at load time (which would be wasteful if most code paths never touch the association), the ORM defers fetching an association until the moment application code actually accesses it - typically via a lazy-loading proxy or collection wrapper. That deferral is a **deliberate, usually beneficial design choice**: most queries against an Order don't need its line items, and eagerly joining them on every load would waste bandwidth and rows. The problem isn't that lazy loading exists - it's that a loop which touches the lazy association once per parent, with no awareness that it's about to do this 50 times, ends up issuing the fetch 50 separate times instead of once, because each access is evaluated independently with no batching context connecting it to the other 49. ## Why making everything eager is a bad trade The naive 'fix' of switching the line-items association to eager loading globally trades one problem for another: every single query that loads an Order - even ones that only need the order's total and status for a summary list - now also joins or separately fetches all of its line items, whether needed or not. On a hot path that lists thousands of orders without ever showing their line items, this is strictly worse: - more data moved over the wire, - more rows joined, - and (if done via a `SQL JOIN` across a one-to-many) a multiplied result set that then has to be deduplicated in application code. The trade-off is fundamentally per use case, not per mapping: the same association should sometimes be lazy and sometimes eager depending on what a specific screen or endpoint actually needs, which argues for controlling fetch strategy at the query level rather than baking one choice permanently into the mapping. ## The standard fixes The standard fixes, roughly in order of how surgical they are: - **(1) A fetch join scoped to one query** - e.g. a JPQL/HQL query with `JOIN FETCH`, or an explicit eager-fetch hint - that eagerly pulls line items only for that specific query, leaving every other load of Order untouched. - **(2) Batch fetching / subselect fetching**, where the ORM (Hibernate's `@BatchSize` or subselect fetch mode is a well-known example) still defers the fetch but, on the first access, loads line items for all orders already in the current result set with one `IN (...)` query instead of one query per order, cutting N+1 down to roughly 2 total queries. - **(3) A purpose-built projection or DTO query** that selects only the columns the screen actually renders, bypassing the full entity graph and its lazy proxies entirely, which is usually the best choice for read-heavy, high-traffic list views. Which of these to reach for depends on whether the full domain objects are still needed downstream (favoring a fetch join or batch fetch) or whether the endpoint is a pure read view (favoring a DTO projection). ## How it shows up in production In production, N+1 shows up as a page or API endpoint that is mysteriously slow under realistic data volumes despite looking fine with a handful of test rows, and it's usually diagnosed by turning on SQL statement logging (or a tool like Hibernate's statistics, or an APM's query-count-per-request metric) and noticing the query count scales linearly with the number of rows on the page rather than staying constant. It's one of the most common production performance bugs in ORM-based systems precisely because it's invisible in development with tiny datasets and only bites once a list grows to dozens or hundreds of rows - a report or dashboard endpoint that joins several such lazy associations can turn what should be a two-query page load into hundreds of round trips, each carrying real network latency.
- How would you detect an N+1 problem in a codebase before it shows up as a production incident?Turn on SQL statement logging in a test or staging environment and watch for query counts that scale with the number of rows returned by an outer query, rather than staying constant - many ORMs also expose this directly, such as Hibernate's statistics API reporting query counts per session. Tools that assert a maximum query count per test (a 'query count assertion') catch regressions automatically in CI before they reach production.
- Why doesn't switching an association to eager loading everywhere count as a real fix?Eager loading is a mapping-wide default, so it applies to every query that touches that entity, including the many code paths that never need the association - those now pay for a join or extra fetch they don't use, increasing data transferred and, for one-to-many eager joins, multiplying and requiring deduplication of the parent rows in the result set. The right fetch strategy is a property of the specific query/use case, not a permanent property of the mapping.
- What's the difference between fixing N+1 with a JOIN FETCH versus with a batch/subselect fetch?A JOIN FETCH pulls the association in the very same SQL statement as the parent query, at the cost of a bigger joined result set that may duplicate parent rows for a one-to-many. A batch/subselect fetch keeps the parent query separate but, once any lazy association on the result set is touched, issues one additional query that loads the association for every parent already fetched via an IN (...) clause, trading a slightly higher query count (2 instead of 1) for a simpler, non-duplicated result shape.
Like sending a separate courier to fetch each ingredient for a 50-dish order one dish at a time, instead of sending one courier with the full shopping list to the store once.
saying these in an interview costs you the question
- Suggests eager-loading every association by default to prevent N+1
- Doesn't recognize that query count should be checked against realistic data volumes, not toy datasets
- Can't explain why lazy loading exists in the first place before criticizing it
- Assumes N+1 only happens with ORMs and not with hand-written per-row queries
- Proposes caching as the first fix instead of fixing the query pattern