What happens to the result of a JPQL query that joins two different collection associations of the same entity in one statement, and how do you avoid the row explosion?
answer
- Two collections = n × m rows
- count/sum inflated by fan-out
- DISTINCT hides rows, not bad aggregates
- EXISTS / IS NOT EMPTY to test, not join
- Split into one query per collection
basics
~20 sThe two collections multiply: a parent with 5 of one and 4 of the other yields 20 rows. Results and any aggregates are inflated. Split into separate queries, use EXISTS subqueries for filtering, or aggregate per collection separately.
solid answer
~60 sJoining two collections off the same root produces a **Cartesian product between the children**. `from Order o join o.items i join o.payments p` gives one row per (item, payment) pair — 5 items and 4 payments become 20 rows. The root entity is returned 20 times (as the same managed instance), and worse, any aggregate is wrong: `count(i)` returns 20, `sum(i.amount)` counts each item four times. `DISTINCT` hides the duplicate parent references but cannot repair aggregates, and it makes the database do a real distinct pass over a bloated row set. Remedies, in order of preference: don't join what you only need to *test* — use `EXISTS` or `IS NOT EMPTY` for filtering; compute each aggregate in its own query or in a correlated subquery; and if you need the actual children, run one query per collection, since the persistence context stitches the results onto the same parent instances. The same multiplication rule is why fetching two collections at once is dangerous, only there the row set is materialised into objects as well.
code
java · 10 lines// inflates: one row per (item, payment) pair
em.createQuery("select o.id, count(i) from Order o join o.items i join o.payments p group by o.id");
// correct: test the second collection without joining it
em.createQuery("""
select o.id, count(i)
from Order o join o.items i
where exists (select 1 from Payment p where p.order = o and p.status = 'FAILED')
group by o.id
""");go deeper
Know that joining two collections multiplies rows and that the same parent appears many times.
Explain the n×m fan-out, why aggregates are wrong, and that DISTINCT only deduplicates references.
Diagnose inflated counts in reports, restructure with EXISTS or per-collection queries, and explain why row limits break on multiplied rows.
Treat cardinality as a query-design constraint: decide which collections a query may traverse, standardise the id-then-details pattern, and keep aggregates in single-cardinality queries.
## Why the rows multiply A join is a relational operation on rows, not on objects. Joining `Order` to its `items` gives one row per item. Joining the same order to its `payments` as well gives one row per **combination**, because nothing relates an item to a payment — the database has no reason to pair item 1 only with payment 1. With `n` items and `m` payments you get `n × m` rows per order. Add a third collection and it is `n × m × k`. ``` select o from Order o join o.items i join o.payments p where o.id = :id ``` For an order with 5 items and 4 payments, this returns a list of 20 elements, all pointing at the same `Order` instance (the persistence context guarantees one managed instance per identifier, so it is one object, repeated). ## Why it matters more than "just duplicates" 1. **Aggregates are silently wrong.** `select o.id, count(i), sum(p.amount) from Order o join o.items i join o.payments p group by o.id` reports 20 items and four times the payment total. This is the classic "fan-out" bug: the numbers look plausible, so it ships. 2. **Volume.** The database materialises and transfers `n × m` rows. On an order with 200 items and 50 payments that is 10,000 rows for data that is really 250 rows. 3. **DISTINCT is not a fix.** `select distinct o` deduplicates the returned references, but the 20 rows were still produced, sorted and shipped. And distinct cannot undo an inflated `sum`. 4. **Pagination breaks.** Any row-based limit applies to the multiplied row set, so "first 20 orders" may be a fraction of one order. ## How to avoid it **Filter with EXISTS instead of joining.** If the second collection only appears in the WHERE clause, you don't need it in the FROM clause at all: ``` select o from Order o join o.items i where i.sku = :sku and exists (select 1 from Payment p where p.order = o and p.status = 'FAILED') ``` No multiplication, and the optimizer can short-circuit the subquery. `IS NOT EMPTY`, `SIZE(o.payments) > 0` and `MEMBER OF` are the compact spellings of the same idea. **One aggregate per query.** Compute item counts in one query grouped by order, payment sums in another, and combine in the application. Or use scalar correlated subqueries in the select list, each independent of the others. **One query per collection when you need the data.** Run the query for items, then the query for payments, both restricted to the same order ids. Because both return the *same* managed `Order` instances within one persistence context, the application sees a fully populated object graph without any Cartesian product. **Aggregate in a subquery and join that.** In HQL you can join a derived root (a subquery in the FROM clause) that has already collapsed one collection to one row per order, so the join no longer multiplies. ## The diagnostic habit When a count is inexplicably too large, or a result list has more elements than you expect, count the collection-valued joins in the query. One collection join multiplies by the collection size (usually acceptable, and removed by DISTINCT); two multiply by the product (rarely acceptable). To-one joins never multiply — they add at most one row per existing row — which is why `join o.customer c` is safe to add anywhere. ## A note on ordering and limits Because the multiplication happens in SQL, `ORDER BY` and `setMaxResults` operate on the inflated rows. If you must limit, restrict the root entities first — for example select ids with a non-multiplying query, then fetch details by id. That two-step shape is the general escape hatch whenever row multiplication and row limits meet. ## Summary rule Join a collection when you need its columns in the result. Use EXISTS when you only need to test it. Never join two collections of the same root in one statement unless you genuinely want every pair — and if you do, say so explicitly, because the next reader will assume it is a bug.
- Does adding DISTINCT make the two-collection join correct?Only for the list of parent references. DISTINCT removes duplicate rows after the database has already produced and sorted the full Cartesian product, so the cost remains, and it cannot repair aggregates such as count or sum, which were computed over the inflated rows. Fixing the query shape is the only real remedy.
- Why is joining two to-one associations harmless while joining two collections is not?A to-one join matches at most one row on the other side, so it adds columns without adding rows. A collection join matches many rows, and two independent many-sides pair combinatorially because nothing relates them to each other. Cardinality, not the number of joins, drives the explosion.
Joining items and payments is like pairing every dish on a menu with every wine on the list: you asked for two lists and got a matrix.
saying these in an interview costs you the question
- Believing DISTINCT makes counts and sums correct
- Assuming Hibernate collapses the two collections into their proper sizes
- Thinking duplicate parents mean duplicate objects rather than repeated references
- Adding setMaxResults on top of a multiplied row set and expecting N parents
- Joining a collection purely to write a WHERE condition on it