skip to content

Some teams mark every JPA association fetch = FetchType.EAGER so that 'nothing fails later when the data is needed'. What does that cost at runtime, and what should they do instead?

level: middleimportance: must knowfreq 65%

answer

  1. @ManyToOne/@OneToOne default = EAGER
  2. EAGER applies to every load path
  3. JPQL + EAGER to-one = N+1
  4. EAGER collection = joined rows, cartesian product
  5. LAZY default + explicit fetch join / entity graph

basics

~20 s

EAGER fetches the association on every load path, even queries that never use it — extra selects per row or cartesian products from joined collections. It cannot be switched off per query. Map everything LAZY and fetch explicitly per use case with fetch joins or entity graphs.

solid answer

~60 s

`EAGER` is a *mapping-wide* decision that every query then has to live with. Consequences: - **It fires on every path.** `em.find`, JPQL, Criteria, even a query that only needs two columns of the root — the graph is loaded anyway. - **JPQL does not join for you.** A JPQL query returning 200 rows with an EAGER `@ManyToOne` typically issues 200 extra selects afterwards to satisfy it. EAGER causes N+1 rather than preventing it. - **EAGER collections multiply rows.** Hibernate joins them, so one parent with 50 children is 50 rows; two eager collections give a cartesian product, and two `List`s throw `MultipleBagFetchException`. - **You cannot opt out.** A fetch join can *add* to the plan; nothing removes an EAGER association for one query. The only escapes are bytecode enhancement or projections. Remember `@ManyToOne` and `@OneToOne` are EAGER **by default** in JPA, so "EAGER everywhere" is often just nobody having overridden them. The alternative: map every association `LAZY`, then declare the fetch plan per use case with `join fetch` or an `EntityGraph`, use `@BatchSize` as a safety net, and return DTO projections for read-only screens.

code

java · 15 lines
java
@Entity
class Order {
    @ManyToOne(fetch = FetchType.LAZY)   // JPA default would be EAGER
    private Customer customer;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 25)
    private Set<OrderItem> items = new HashSet<>();
}

// the one screen that needs the customer says so:
List<Order> orders = em.createQuery(
        "select o from Order o join fetch o.customer where o.status = :st", Order.class)
    .setParameter("st", Status.OPEN)
    .getResultList();

go deeper

for a junior

Know the JPA defaults, that EAGER loads the association on every path, and that the recommended default is LAZY plus explicit fetching when needed.

for a middle

Explain both failure modes — extra selects per row for to-ones, row multiplication for collections — and show a fetch join or entity graph as the alternative.

for a senior

Emphasise that EAGER cannot be overridden downward, discuss the pagination interaction, batch fetching as a safety net, and the lazy @OneToOne caveat.

for a principal

Set it as a codebase convention with enforcement — lazy-by-default mappings, DTOs at boundaries, and automated statement-count checks so a re-added EAGER fails the build.

## What EAGER actually promises `FetchType.EAGER` means: whenever an instance of this entity is loaded, this association must be fully loaded too, before the caller sees the object. It is a guarantee about the *entity*, not about a query, which is exactly why it is so damaging — the guarantee applies to code that never wanted it. JPA's defaults matter here: `@ManyToOne` and `@OneToOne` are **EAGER** by default; `@OneToMany` and `@ManyToMany` are LAZY. So most "EAGER everywhere" codebases never made a decision at all — they simply never wrote `fetch = FetchType.LAZY` on their to-one associations. ## Cost 1 — extra statements on every read `em.find(Order.class, id)` with an EAGER `customer` can join and stay a single statement. But a **JPQL/Criteria query** does not automatically join eager associations: Hibernate runs your query, then satisfies each eager association for each returned root. A query returning 200 orders becomes 1 + 200 statements, unless those customers happen to be in the persistence context or second-level cache. That is the N+1 problem *caused by* the annotation that people add to avoid it. Worse, it is transitive. If `Customer` eagerly maps `Address`, and `Address` eagerly maps `Country`, one order drags a chain. Large eager graphs can turn a trivial lookup into dozens of statements. ## Cost 2 — row multiplication on collections An EAGER `@OneToMany` is fetched by joining. One order with 50 lines becomes 50 rows, all carrying duplicated parent columns. Add a second eager collection and the row count is the product of the two — the cartesian product. Hibernate refuses the worst case: fetching two `List`-typed (bag) collections throws `MultipleBagFetchException` at query time or bootstrap. That exception is genuinely helpful; the silent cartesian product between a `Set` and a `List` is not. EAGER collections also break pagination: a paged query over an entity with an eagerly joined collection hits the same in-memory pagination fallback as an explicit collection fetch join. ## Cost 3 — you cannot opt out per query This is the structural problem. Fetch joins and entity graphs are **additive**: they let a query fetch *more*. There is no portable way to say "for this query, do not load that EAGER association". A `LAZY` mapping, by contrast, is a *default you can override upward* whenever a use case needs the graph. Lazy-by-default is strictly more expressive. (JPA's `javax.persistence.fetchgraph` hint nominally restricts to the named attributes, but providers historically treat EAGER mappings as always-fetched, so it is not a reliable escape.) ## Cost 4 — memory and the persistence context Every eagerly pulled entity becomes managed: an object, plus Hibernate's load-time snapshot for dirty checking. Reading a page of 200 orders can materialise thousands of objects nobody reads, all of which are then dirty-checked at flush. ## Why people do it anyway Because the alternative bites them: touching a lazy association after the session is closed fails. The correct response is to define the fetch plan for each use case and to hand the layer above data it can use (DTOs or already-initialised graphs), not to load everything always. "EAGER everywhere" trades a loud, local failure for a quiet, global cost. ## The alternative, concretely 1. **Map every association LAZY**, including to-ones: `@ManyToOne(fetch = FetchType.LAZY)`. 2. **State the fetch plan per query.** `left join fetch o.customer` when the screen needs the customer; an `EntityGraph` when you want the plan expressed declaratively and reused. 3. **Add `@BatchSize(size = n)`** to associations that are frequently walked in loops. If something is dereferenced per row anyway, batch loading turns N selects into N/n. 4. **Project DTOs** for read-only views so no association is walked at all. 5. **Enforce it.** Turn on statement counting in tests for key endpoints, so a re-added EAGER shows up as a failing assertion. ## The lazy to-one caveat `@ManyToOne(fetch = LAZY)` works by substituting a proxy for the target, which is fine. `@OneToOne` on the *non-owning* side cannot be lazy without help: Hibernate must know whether the row exists to decide between a proxy and `null`, so it issues a select anyway. `@OneToOne(optional = false)` on the owning side, or build-time bytecode enhancement, restores laziness. Mentioning this is what separates a candidate who has read about laziness from one who has fought with it.

  • If EAGER guarantees the association is loaded, why does it still produce N+1 selects?
    Because the guarantee is about the entity's state after loading, not about how the loading is done. A JPQL or Criteria query executes the SQL you wrote, which does not join the eager association, so Hibernate then satisfies the guarantee by issuing an additional select per returned root that is not already in the persistence context or second-level cache. Only an explicit fetch join or entity graph makes it a single statement.
  • Can a single query opt out of an EAGER mapping?
    Not portably. Fetch joins and entity graphs are additive — they can add associations to the fetch plan but not remove ones the mapping declares EAGER. The practical escapes are selecting a DTO projection instead of the entity, or enabling build-time bytecode enhancement with lazy loading, which is a build-level change rather than a per-query one. This asymmetry is the strongest argument for mapping everything LAZY.

saying these in an interview costs you the question

  • Believing EAGER prevents N+1 rather than frequently causing it
  • Not knowing @ManyToOne and @OneToOne are EAGER by default
  • Claiming a fetch join or entity graph can disable an EAGER association for one query
  • Using EAGER to avoid lazy-initialisation failures instead of defining a fetch plan
  • Assuming @OneToOne on the inverse side becomes lazy just by writing fetch = LAZY

context