skip to content

A JPQL query returns 500 entities and the log shows 501 statements, even though the code never dereferences any association. The entity has a @ManyToOne mapped with fetch = FetchType.EAGER. Why does that mapping produce extra statements for a query result when loading the same entity by primary key produces a single joined statement?

level: middleimportance: must knowfreq 62%

answer

  1. find() = Hibernate's plan (join); query = your SQL (secondary selects)
  2. EAGER is a contract satisfied after the result set
  3. count = distinct unseen ids, not row count
  4. EAGER collections break setMaxResults pagination
  5. can upgrade LAZY per query, cannot downgrade EAGER

basics

~20 s

find() builds its own fetch plan and can add a join. A JPQL query is executed largely as written, so an EAGER association that is not join-fetched must be satisfied afterwards — one secondary select per distinct associated row. No code needs to touch it.

solid answer

~60 s

There are two different loading paths. `em.find(...)` has no user-supplied SQL to respect: Hibernate builds the statement from the entity's fetch plan, so an EAGER to-one becomes an outer join and you see one statement. A **JPQL/HQL or Criteria query is your SQL**. Hibernate translates the select clause you wrote; it does not silently rewrite it into a join for every eager association. But EAGER is a hard contract — by the time entities are returned, the association must be populated. So after the main result set comes back, Hibernate issues secondary selects to satisfy it, one per distinct associated identifier not already in the persistence context. That is 1 + N with no loop anywhere in your code. The fix is at the mapping level: make the association LAZY and add an explicit fetch join or entity graph on the queries that actually need it. You cannot demote EAGER per query, which is precisely why the default is considered harmful. Batch fetching can reduce the count but does not remove the coupling.

code

java · 9 lines
java
@ManyToOne(fetch = FetchType.EAGER)   // the JPA default
Customer customer;

// path A: one statement, customer outer-joined in
Order one = em.find(Order.class, 1L);

// path B: 1 + N statements, no association ever touched
List<Order> all = em.createQuery("select o from Order o", Order.class)
                    .getResultList();

go deeper

for a junior

Know that EAGER associations can cause extra queries on list results even without touching them, and that LAZY plus explicit fetching is the convention.

for a middle

Explain the two loading paths — find builds the SQL and can join, a query is honoured as written with secondary selects afterwards — and that the count follows distinct ids.

for a senior

Add the pagination interaction with eager collections, the native-query case, and why the mapping change is the fix while batching is a mitigation.

for a principal

Argue the policy: entity mappings carry the cheapest safe default, use cases declare fetch plans or use projections, and eager mappings are treated as a defect in review because their cost is unbounded and non-local.

## Two loaders, two behaviours Hibernate has more than one way to produce entities, and they treat fetch plans differently. **Identifier-based loading** — `em.find()`, `em.getReference()` initialisation, association initialisation. Here Hibernate composes the SQL itself from the entity's mapping. Nothing constrains the shape of the statement, so eager to-one associations are folded in as outer joins and a single statement returns everything. **Query-based loading** — JPQL/HQL, Criteria, and native queries. Here *you* stated what to select. Hibernate translates your query. It does not go through the mapping and quietly add a join for every eager association, partly because doing so would change the semantics of your query (row multiplication, ordering, pagination) and partly because the query language gives you `join fetch` for exactly that purpose. But the EAGER contract still has to be honoured before the entities are handed back. So Hibernate performs a second phase: for each eager association not already satisfied, it loads the missing targets — by default, one select per distinct identifier that is not already in the persistence context. Hence: **one query for your 500 rows, plus up to 500 more**, with no loop in sight. Simply returning the list from a repository method is enough. ## Why the count varies The number of secondary selects is the number of **distinct** associated ids not already managed. If 500 orders reference 12 customers, you see 12 extra statements. If each references a different customer, you see 500. That data-dependence is why the same code looks fine on a seeded test dataset and catastrophic on real data. A second-level cache hit also avoids a statement, so the count can differ between a cold and a warm process. ## Eager collections make it worse An EAGER `@OneToMany` in a query result is worse in two ways. If Hibernate joins it, the parent rows multiply by the number of children, which breaks `setMaxResults` pagination — Hibernate then has to paginate in memory, and older versions logged the notorious warning about applying pagination in memory. If it does not join, you get a full collection select per parent. Two eager collections cannot both be join-fetched into one statement without a Cartesian product. ## Why you cannot fix it at the query The asymmetry is the heart of the matter: - A **LAZY** mapping can be upgraded per query with `join fetch` or an entity graph, so each use case pays only for what it needs. - An **EAGER** mapping cannot be downgraded per query. There is no `fetch none` in JPQL. Every query returning that entity — exports, counts-by-example, batch jobs that only need the id — pays. So the correct fix is structural: change the mapping to `fetch = FetchType.LAZY`, then add the fetch plan to the queries whose use case actually reads the association. ## What each mitigation actually buys - **Making it LAZY + explicit fetch plans** — removes the problem at the source and makes each use case's cost readable in the query. This is the answer to give. - **Batch fetching knobs** — turn N secondary selects into roughly N/size selects using `in (?, ?, ...)`. Genuinely useful, but a mitigation: the query still does not say what it needs, and cost is still proportional to the result set. - **DTO projections** — `select new com.x.OrderRow(o.id, c.name) from Order o join o.customer c` returns exactly the columns needed and never constructs a managed entity, so no fetch plan applies at all. For read-only endpoints this is frequently the best option available. - **Second-level caching** — hides the symptom while the cache is warm and returns after every deploy or eviction. Not a fix. ## Diagnosing it specifically The signature of this variant is that the extra statements appear **immediately after the main query, before any of your code runs**, and they are all keyed lookups on the same table. If you see a burst of identical `select ... from X where id=?` statements right after a list query and your code never touches `X`, look for an EAGER mapping rather than for a loop. ## The summary line "`find()` builds the SQL, so it can join. A query is my SQL, so Hibernate honours EAGER afterwards with one select per associated row — which is why to-one associations should be mapped LAZY and fetched explicitly where needed."

  • Does a native SQL query returning entities behave the same way?
    Yes, and arguably more starkly: Hibernate cannot rewrite your native SQL at all, so any eagerly mapped association that your SQL did not supply must be filled in with secondary selects after the fact. That makes native queries a common surprise source for people who assumed writing raw SQL gives full control over the statements issued. The way out is the same — lazy mappings, or project into a DTO rather than into managed entities.
  • Why does an EAGER @OneToMany interfere with setMaxResults pagination?
    Because join-fetching a collection multiplies the parent rows by the number of children, so a SQL row limit no longer corresponds to a limit on distinct parents. Hibernate therefore has to fetch the full result and apply the limit in memory, which defeats the purpose of pagination and can pull an enormous result set into the heap. This is why collections should be lazy and paginated queries should fetch collections in a separate step.

saying these in an interview costs you the question

  • Assuming Hibernate rewrites JPQL to join every eager association automatically.
  • Thinking no extra queries can happen if the code never touches the association.
  • Proposing EAGER as the cure for N+1.
  • Believing a native query gives complete control over the statements Hibernate issues.
  • Treating batch fetching as a fix rather than a mitigation of a missing fetch plan.

context