A JPQL query left-join-fetches a one-to-many collection and also calls setMaxResults; Hibernate logs a warning that firstResult/maxResults were specified with a collection fetch and are being applied in memory. What is Hibernate actually doing, and why is that dangerous in production?
answer
- fetch join collection = N rows per parent
- limit would truncate a collection
- HHH000104 / HHH90003004
- whole result set materialised, then sliced
- fail_on_pagination_over_collection_fetch
basics
~20 sA collection fetch join returns one SQL row per child, so a SQL LIMIT would cut a parent's collection in half. Hibernate drops the limit, runs the whole query, builds every matching parent in memory, then slices — unbounded reads and heap use.
solid answer
~50 sWith `left join fetch o.items`, one order with ten items is ten SQL rows. Hibernate can no longer map "20 rows" to "20 orders", and a SQL `LIMIT 20` would return a *partial* collection for the last parent — silently wrong data. So Hibernate chooses correctness: it strips `limit/offset` out of the generated SQL, executes the **entire** matching result set, de-duplicates parents while assembling their collections, and only then applies `firstResult`/`maxResults` to that in-memory list. Hibernate 5 logs `HHH000104`, Hibernate 6 logs `HHH90003004`; the text is the same. The danger is that results stay *correct*, so nothing fails in test data. In production the query reads the whole table plus its children on every request: latency scales with total rows, heap holds every entity, and the persistence context balloons. It typically surfaces as an OutOfMemoryError or a timeout months later. Fetch-joining a to-one association does not multiply rows, so paging stays in SQL. The fixes are a two-query id-then-fetch, batch/subselect fetching, or a DTO projection.
code
java · 7 linesList<Order> page = em.createQuery(
"select o from Order o left join fetch o.items " +
"where o.status = :st order by o.createdAt desc, o.id desc", Order.class)
.setParameter("st", Status.OPEN)
.setFirstResult(0)
.setMaxResults(20) // WARN: applied in memory, SQL has no limit
.getResultList();go deeper
Recognise the warning and know it means the page limit was not sent to the database. Knowing that collection fetch joins and paging do not mix is enough.
Explain the row-multiplication mechanic, why truncating in SQL would corrupt a collection, and name at least one fix such as the two-query approach.
Talk about the production failure mode — heap and latency scaling with table size while results stay correct — and about enforcing the fail-fast setting in tests plus choosing between batch fetching, two queries and projections.
Position it as a class of defect that must be caught by tooling rather than review: fail-fast configuration, query-count assertions in tests, and a fetch-strategy convention that keeps collections lazy by default.
## Why the row count stops matching the entity count A fetch join over a collection is an ordinary SQL join. If `Order 1` has 3 items and `Order 2` has 7, the join produces 10 rows, and Hibernate assembles them into 2 `Order` entities each with its list populated. The result *list* has 2 elements; the result *set* had 10 rows. Pagination in SQL is expressed in rows. So `limit 20` means "20 join rows", which could be one order with 20 items, or five orders where the fifth is missing half its items. That last outcome is the killer: Hibernate would hand you an `Order` whose `items` collection is populated but **incomplete**, and because the collection is initialised, nothing would ever load the rest. You would silently ship wrong data. ## Hibernate's choice Hibernate refuses to be wrong. When it detects `firstResult`/`maxResults` together with a fetched collection it: 1. Removes the limit clause from the generated SQL. 2. Executes the query unbounded — every row matching the `WHERE`. 3. Streams the JDBC rows, de-duplicating root entities by identifier and attaching children to the right parent. 4. Applies the offset and page size to the resulting in-memory `List`. And it warns you: `HHH000104: firstResult/maxResults specified with collection fetch; applying in memory` in Hibernate 5, `HHH90003004` with the same message in Hibernate 6. Hibernate 6 also lets you turn that warning into an exception (`hibernate.query.fail_on_pagination_over_collection_fetch = true`), which is the setting you want in CI so the trap can never reach production unnoticed. ## Why it is dangerous rather than merely slow - **The results are correct.** No exception, no wrong row. It passes review and passes tests with 50 rows of seed data. - **Cost scales with the whole table, not the page.** Page 1 of a 5-million-row table reads 5 million rows plus their children. - **Memory is the first casualty.** Every matched entity, its children, and the persistence context's snapshot copies for dirty checking sit in the heap simultaneously. This is a classic OutOfMemoryError source. - **It gets worse silently.** Latency and heap grow with data volume; the code never changed, so nobody suspects the query. - **Connection hold time** grows with it, so a slow query drains the pool under load. ## Related things people confuse it with - **To-one fetch joins are fine.** `join fetch o.customer` yields one row per order, so the row count equals the entity count and Hibernate keeps `limit` in SQL. The trap is specific to collection-valued (`@OneToMany`/`@ManyToMany`) fetches. - **`distinct` does not save you.** In Hibernate 5, `select distinct o` both de-duplicated in Java and (unless `hibernate.query.passDistinctThrough=false`) pushed a useless `DISTINCT` into SQL, which cost a sort of the whole join. In Hibernate 6 root entities are de-duplicated automatically and that hint was removed. Either way, de-duplication is not pagination. - **Two collection bags cannot even be fetched together.** Fetch-joining two `List` associations throws `MultipleBagFetchException`, a different symptom of the same row-multiplication problem. ## The fixes, in the order to try them 1. **Do not fetch the collection at all.** Page the parents with a plain query, project what the screen needs, and load children only when the screen actually shows them. 2. **Two-query id-then-fetch.** Query a page of ids with SQL-level paging, then a second query with the fetch join restricted to `where o.id in :ids`. Both queries are bounded. 3. **Batch or subselect fetching.** Leave the collection lazy and let `@BatchSize(size = n)` or `@Fetch(FetchMode.SUBSELECT)` initialise all pages' collections in a handful of extra statements — the page query itself stays paginated in SQL. 4. **DTO projection.** Select scalar columns for the list view; the join can still multiply rows, so aggregate or do a second query for children. ## How to catch it Turn on SQL logging in tests and assert that the page query carries a `limit`. Enable the fail-on-pagination setting in the test profile. Grep the codebase for `join fetch` on a collection appearing in the same query builder as a page size.
- Would the same warning appear if the query fetch-joined a @ManyToOne instead?No. A to-one fetch join produces exactly one row per root entity, so a SQL row limit maps cleanly onto an entity limit and Hibernate keeps limit/offset in the generated SQL. The in-memory fallback exists only because collection joins multiply rows and a SQL limit could truncate a parent's collection.
- Does adding DISTINCT to the JPQL make pagination safe again?No. DISTINCT only removes duplicate root references from the returned list; the join still produces one row per child, so a SQL limit would still cut a collection short and Hibernate still paginates in memory. In Hibernate 5 it also pushed a real SQL DISTINCT that sorted the whole join for nothing, which is why the passDistinctThrough hint existed; Hibernate 6 de-duplicates automatically and removed the hint.
saying these in an interview costs you the question
- Claiming the warning is harmless because the results are correct
- Adding DISTINCT and believing pagination is now done in SQL
- Thinking the limit still reaches the database and Hibernate only 'adjusts' it
- Assuming it only affects @OneToMany and not @ManyToMany
- Fixing it by raising the JVM heap instead of bounding the query