You need a correctly sized page of parent entities with their child collections already initialised, without Hibernate paginating in memory. Describe the two-query approach and what each query looks like.
answer
- query 1: ids only + limit/offset
- query 2: fetch join where id in :ids
- repeat the ORDER BY — IN has none
- chunk ids past the IN/parameter limit
- @BatchSize is the zero-query-rewrite alternative
basics
~20 sQuery one selects only ids with setFirstResult/setMaxResults, so the limit reaches SQL. Query two fetch-joins the collections with 'where p.id in :ids' and no limit — bounded by the id list. Re-apply the ORDER BY, because IN does not preserve order.
solid answer
~50 s**Query 1 — the page:** `select o.id from Order o where ... order by o.createdAt desc, o.id desc` with `setFirstResult`/`setMaxResults`. No fetch join, so the dialect pushes `limit/offset` into SQL and the database returns at most *n* ids. **Query 2 — the graph:** `select o from Order o left join fetch o.items where o.id in :ids order by o.createdAt desc, o.id desc`. No limit is needed: the `IN` list already bounds it to the page. The `ORDER BY` must be repeated, because `IN` says nothing about ordering. Details that matter: keep the two orderings identical or the page contents shift under concurrent writes; chunk the id list if it can exceed the database's parameter or `IN`-list limit; Hibernate 6 de-duplicates root entities so `distinct` is unnecessary; and you still cannot fetch two `List` collections in query 2 (`MultipleBagFetchException`) — use a third query or `@BatchSize`. Alternatives with the same effect: leave the collection lazy and rely on `@BatchSize`/`FetchMode.SUBSELECT`, or select a DTO projection for the list view.
code
java · 13 linesList<Long> ids = em.createQuery(
"select o.id from Order o where o.status = :st " +
"order by o.createdAt desc, o.id desc", Long.class)
.setParameter("st", Status.OPEN)
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
List<Order> orders = ids.isEmpty() ? List.of() : em.createQuery(
"select o from Order o left join fetch o.items " +
"where o.id in :ids order by o.createdAt desc, o.id desc", Order.class)
.setParameter("ids", ids)
.getResultList();go deeper
Know that the fix is 'ids first, then fetch by those ids', and that only the first query carries the page limit.
Write both queries correctly, including the repeated ORDER BY, and explain why the second needs no limit.
Discuss the trade-offs: an extra round trip, id-list size limits, statement-cache churn and IN-clause padding, non-atomicity across the two queries, and when @BatchSize or a DTO projection is the better answer.
Make it a convention rather than a one-off: lazy-by-default mappings, a standard paging helper, and guidance on when a list screen should not be returning entity graphs at all.
## The idea Split the two jobs that conflict. *Choosing which parents belong on the page* is a row-limited operation the database does well. *Loading each chosen parent's children* is a bounded lookup that needs no limit at all. Do them in separate statements and neither one has to compromise. ## Query 1 — select ids only ``` select o.id from Order o where o.status = :st order by o.createdAt desc, o.id desc ``` with `setFirstResult`/`setMaxResults`. Because nothing is fetch-joined, Hibernate emits a real `limit ? offset ?`. It returns scalars, not entities, so nothing enters the persistence context and the memory cost is a list of longs. If the ordering column is indexed, the database can often satisfy this from the index alone. ## Query 2 — fetch the graph for those ids ``` select o from Order o left join fetch o.items where o.id in :ids order by o.createdAt desc, o.id desc ``` No `setMaxResults` here. The `IN` clause is the bound: at most *page size* parents, each with its children. The join still multiplies rows, but the multiplication is over 20 parents, not the whole table, so it is fine. Three things people get wrong: 1. **Ordering.** `where id in (...)` returns rows in whatever order the plan produces. If you skip the `ORDER BY` in query 2, your page comes back scrambled. Repeat the exact same ordering. (If the sort key is expensive or not available on the entity, sort the results in Java by the id list's index instead.) 2. **Consistency between the two queries.** They run as two statements. Under concurrent writes the set of ids can no longer match the predicate by the time query 2 runs — a deleted row simply disappears from the page. Running both inside one transaction (and, if the guarantee matters, at an isolation level that gives a stable snapshot) bounds the anomaly; usually a slightly short page is acceptable for a list screen. 3. **Id-list size.** Every database has a ceiling on bind parameters or `IN` elements (Oracle's 1000-element `IN` limit is the famous one; PostgreSQL's practical limit is the 65535 parameter cap). For normal page sizes this never bites, but if you reuse the pattern for a bulk load, chunk the ids. Hibernate 6's `hibernate.query.in_clause_parameter_padding` pads the list to powers of two so the statement cache sees far fewer distinct SQL strings — worth enabling when you use this pattern a lot. ## Variants of the same trick - **Entities instead of ids.** Query 1 can select the entities themselves (paged, no fetch join); query 2 then runs `select o from Order o left join fetch o.items where o in :orders`. Because the parents are already managed, query 2's job is purely to initialise the collections, and Hibernate wires them onto the instances already in the persistence context — you can even ignore query 2's return value. This costs more than selecting ids but keeps the code simple. - **`@BatchSize(size = 25)` on the collection.** Leave it lazy. Page the parents in one SQL-limited query; when the first `items` collection is touched, Hibernate initialises up to 25 collections in one statement with an `IN` list of owner ids. One extra query per batch, no query rewriting, and it works for every code path automatically. - **`@Fetch(FetchMode.SUBSELECT)`.** Hibernate re-runs the original query as a subselect to load all collections at once. Careful: with a paged original query, the subselect does **not** carry the limit, so it can load collections for far more parents than the page. Prefer `@BatchSize` when paging. - **DTO projection.** If the screen needs three columns and a child count, `select new ...(o.id, o.total, count(i))` with a `group by` avoids the whole problem. ## Two collections at once Query 2 can fetch-join only one `List`-typed collection; two throw `MultipleBagFetchException` because the double join makes the row multiplication ambiguous for bag semantics. Options: change one to a `Set`, split into a third query on the same id list, or leave the second collection to `@BatchSize`. ## What it costs Two round trips instead of one, and the ordering expressed twice. In exchange, both statements are bounded by the page size, the plan for query 1 is index-friendly, and heap usage no longer depends on table size. For any list screen over a table that grows, that trade is worth making.
- Why does the second query need its own ORDER BY?Because 'where id in (:ids)' places no constraint on the order rows come back in; the database returns them in whatever order its plan produces, which is often index or physical order rather than your sort key. Repeating the identical ORDER BY restores the page's ordering. The alternative is to sort the returned entities in Java by their position in the id list.
- When would you prefer @BatchSize over writing two queries by hand?When many code paths load the same association and you do not want each of them to remember the two-query dance. @BatchSize keeps the association lazy and initialises collections in groups with an IN list of owner ids, so the paged root query stays SQL-limited and the extra statements are bounded and automatic. Hand-written two-query paging wins when you want exactly one extra statement and full control of the fetch plan for one specific screen.
saying these in an interview costs you the question
- Adding setMaxResults to the second query as well, reintroducing the in-memory warning
- Omitting the ORDER BY in the fetch query and returning a scrambled page
- Passing an unbounded id list straight into IN without chunking
- Believing the two queries are atomic so the page can never be short
- Fetch-joining two List collections in the second query and being surprised by MultipleBagFetchException