Switching from EAGER to LAZY on a @ManyToOne association made one of our REST endpoints 4x slower. That’s the opposite of what every Hibernate tutorial promises, and understanding why it happened is more useful than any fetch-type cheatsheet.
The endpoint served an order detail page. The Order had a @ManyToOne Customer, which was previously EAGER. Response time was ~80ms. We switched to LAZY (correctly, as a “best practice”) and response time jumped to ~320ms. The reason: the EAGER mapping had been loading Customer in the same query as Order via a JOIN. LAZY replaced that with a second SELECT — and the second SELECT ran after the session had been handed to Jackson for serialisation, which triggered OSIV to keep the connection open, added latency from the extra round-trip, and eventually started failing under load when connection pool slots ran out waiting for the serialisation thread to finish.
LAZY was still the right answer. The fix was a proper fetch strategy. But “switch everything to LAZY” without understanding the four fetch options and when each one earns its keep is how you trade one performance problem for a different one.
1. The Four Fetch Options on the Same Dataset
Consider a schema with 50,000 Order rows, each with a @ManyToOne Customer and a @OneToMany List<OrderItem>. A list endpoint needs to return all orders with customer name and item count. Here is how each fetch strategy handles it.
LAZY default (the naive path)
With no override: one SELECT for orders, then one SELECT per order to get its customer, and another per order to get its item count if you touch order.getItems().size(). For 50,000 orders that is 100,001 queries. At even 1ms per query, that is 100 seconds. In practice it manifests as a request that never completes and a JDBC connection pool that exhausts at about query #250.
Approximate numbers on a local PostgreSQL instance with 50k rows:
- Query count: 50,001+
- Wall time: never completes (connection pool exhaustion)
- Heap: low (entities loaded on demand)
JOIN FETCH in JPQL
List<Order> orders = em.createQuery(
"SELECT o FROM Order o JOIN FETCH o.customer", Order.class)
.getResultList();
One query with an inner join. Customer data is in the result set. No second select per row.
- Query count: 1
- Wall time: ~280ms (50k rows, JOIN)
- Heap: moderate (all 50k order + customer data loaded)
This works well for a single to-one association. Adding JOIN FETCH o.items to also fetch the collection introduces the Cartesian product problem — covered in the failure modes section below.
@EntityGraph
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("customer");
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.loadgraph", graph)
.getResultList();
Functionally equivalent to JOIN FETCH for a to-one: one JOIN query. The difference is reusability — the graph is defined separately and can be applied to any query or find() call, while JOIN FETCH is embedded in the query string.
- Query count: 1
- Wall time: ~285ms (essentially same as JOIN FETCH)
- Heap: same as JOIN FETCH
@BatchSize
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100)
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<OrderItem> items;
}
When any Customer is accessed on a lazy proxy, Hibernate fetches up to 100 customers in a single WHERE id IN (...) query, using the IDs of the proxies that are in the current session. For 50,000 orders grouped into batches of 100: 500 customer queries plus 500 item queries = 1,000 total queries.
- Query count: ~1,000 (1 initial + 500 customer batches + 500 item batches)
- Wall time: ~420ms (more queries but each is cheap; no JOIN overhead)
- Heap: lower than JOIN FETCH (data loaded in chunks)
BatchSize is the right answer when you cannot predict at query time which associations you’ll need, or when the join would be too expensive (wide columns, many rows on the collection side). Set hibernate.default_batch_fetch_size=50 globally in application.properties to apply it across all lazy associations without per-entity annotations.
2. Under the Hood: How Hibernate Builds a Proxy
When Hibernate loads an entity with a lazy association, it does not leave the association field null. It installs a proxy object — a runtime-generated subclass of the associated entity (or a Hibernate-specific collection wrapper) — in the field. The proxy holds the identifier and a reference back to the current session.
In Hibernate 7, proxies for entity classes are generated using ByteBuddy (replacing the older Javassist approach). ByteBuddy intercepts method calls on the proxy class by inserting an intercept() call before every getter. When any non-identifier getter is called for the first time, intercept() checks whether the proxy’s data is loaded. If not, it validates the session reference, issues a SELECT for that row, populates the proxy’s state, and marks it as initialised. Subsequent calls return the cached value directly.
This is why you get a LazyInitializationException when you access a lazy association outside a session — the proxy’s session reference points to a closed EntityManager. The proxy has the identifier, it knows what to fetch, but the transport is gone. (For a full debugging walkthrough of that exception, see LazyInitializationException: A Debugging Walkthrough.)
For @OneToMany collections, Hibernate installs a PersistentBag, PersistentSet, or PersistentList wrapper. These wrappers also hold a session reference and defer their SELECT until the first call to size(), iterator(), or any method that forces initialisation. This is why a serialiser like Jackson can trigger a lazy load — it calls getItems(), which returns the uninitialised wrapper, then calls size() or iterates, which fires the SQL.
3. When LAZY Is the Wrong Default
The advice “default everything to LAZY” is correct as a starting position but has three common failure cases.
Single-entity reads where you always need the association. A product detail page always shows product plus category. If @ManyToOne Category is LAZY, every product detail request issues two queries: one for the product, one for the category. You gain nothing from deferring that load. A targeted JOIN FETCH in the repository method is the right fix — not flipping to EAGER globally, which would also affect list queries where you don’t need category.
REST endpoints returning DTOs. If your service method projects to a DTO inside the transaction, the lazy proxies are never touched after the session closes. The extra load cost of accessing those fields inside the transaction is zero. In this case, LAZY is irrelevant — the real question is which fields your JPQL projection selects, not what the entity’s fetch type says.
List views with fully predictable expansion. A dashboard that always displays order + customer + item count for every row has a known access pattern. LAZY plus three separate queries per row is worse than one JOIN query. BatchSize gives you a middle path: keep LAZY on the mapping but let Hibernate use IN-clauses when the access pattern is known to touch all rows.
4. The Three Production Failure Modes
N+1: the classic
Load N parent entities, then access a lazy association on each one inside a loop: N+1 queries. The loop is usually invisible — it lives inside a serialiser, a mapper, or a template engine, not in your service code. The fix is BatchSize (if you want to keep LAZY at the entity level) or JOIN FETCH / EntityGraph (if you want a single query).
LazyInitializationException: closed session
This fires when a proxy’s intercept() finds a null or closed session reference. The three most common causes are: access in a controller method after the @Transactional service returned; DTO mapper traversing a lazy collection; JSON serialiser walking the entity graph. The fix is never to let live entity proxies escape the transaction boundary — project to DTOs or use @EntityGraph to initialise what you need before the session closes. Enabling OSIV to make this exception go away keeps the connection open for the full HTTP request lifecycle, which is a connection-pool leak waiting for load to expose it.
Cartesian product from JOIN FETCH on a collection
This is the one most tutorials get wrong. Fetching a to-many collection with JOIN FETCH multiplies result rows. An order with 5 items appears 5 times in the result set. Hibernate deduplicates in-memory, but the database does full work, and with pagination the numbers are wrong: LIMIT 10 applied to a JOIN where each parent appears multiple times is not “first 10 orders” — it is “first 10 rows, which might be 2 orders with 5 items each”.
This is covered in the bad → improved pair below.
Bad → Improved: JOIN FETCH with Pagination
Bad — JOIN FETCH on a collection combined with setMaxResults:
List<Order> orders = em.createQuery(
"SELECT DISTINCT o FROM Order o JOIN FETCH o.items", Order.class)
.setFirstResult(0)
.setMaxResults(20) // WARNING: HHH90003004 in the logs
.getResultList();
Hibernate emits HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory! in the logs. It executes the join without LIMIT, loads the entire result set into memory, and then applies pagination in Java. For a 50k-order table with an average of 8 items each, that is 400,000 rows transferred over the network before pagination runs. The result is correct; the performance is not.
Improved — paginate on parent IDs first, then fetch collection in a second query:
// Step 1: paginate on parent IDs only — clean LIMIT in SQL
List<Long> orderIds = em.createQuery(
"SELECT o.id FROM Order o ORDER BY o.createdAt DESC", Long.class)
.setFirstResult(0)
.setMaxResults(20)
.getResultList();
// Step 2: fetch full graph for those 20 IDs only
List<Order> orders = em.createQuery(
"SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids", Order.class)
.setParameter("ids", orderIds)
.getResultList();
Two queries instead of one, but the database transfers 20 orders worth of data, not 50,000. The pagination is exact. This is the standard “page IDs then hydrate” pattern, and it applies whenever you combine JOIN FETCH on a collection with setMaxResults. For the broader pagination story including keyset pagination, see Hibernate 7 Pagination.
Choosing the Right Strategy: A Quick Reference
| Scenario | Best strategy | Reason |
|---|---|---|
| Single entity, always need one to-one assoc | JOIN FETCH or EntityGraph in the repository method | One query; no proxy overhead |
| List of entities, access pattern unknown at query time | BatchSize (global or per-field) | Avoids N+1 without JOIN explosion |
| List + pagination + collection | ID pagination + second JOIN FETCH query | Correct page count; no in-memory pagination |
| DTO projection, no entity graph needed | LAZY (irrelevant); project in JPQL SELECT NEW | Proxies never touched; no extra queries |
| Always need to-one + large collection on same entity | EntityGraph with subgraph for collection | Single query; graph reusable across endpoints |
See Also
- 📘 LazyInitializationException Debugging Walkthrough — proxy internals, session lifecycle, and the five fixes ranked
- 📘 The Hibernate 7 Persistence Context — entity states, dirty checking, detachment in REST layers
- 📘 Hibernate 7 First-Level Cache — how the identity map interacts with repeated find() calls
- 📘 Choosing the Right Hibernate Association — @OneToMany vs @ManyToMany vs @OneToOne decision tree
- 📘 @OneToMany Done Right — Set vs List, bidirectional sync, MultipleBagFetchException
Frequently Asked Questions
Why is my @ManyToOne EAGER by default?
JPA specifies that @ManyToOne and @OneToOne default to EAGER, while @OneToMany and @ManyToMany default to LAZY. The JPA spec designers reasoned that loading a single related row alongside the owner is cheap. In practice, many @ManyToOne associations involve large entities you rarely need on every query, and the join happens even when your JPQL doesn’t reference the association. Switch to LAZY explicitly and add fetch strategies where needed.
What does hibernate.default_batch_fetch_size do?
It sets a global batch size for all lazy associations and collections in the application. When a lazy proxy is initialised, Hibernate collects up to N proxy IDs from the current session and fetches them all in a single WHERE id IN (...) query. A value of 25–50 is a reasonable starting point. This eliminates N+1 without any per-entity annotation and without requiring JOIN FETCH in every query.
loadgraph vs fetchgraph — what’s the difference?
Both are entity graph hints. loadgraph treats the graph as an override for specified attributes and respects the entity’s mapping fetch type for everything else. fetchgraph treats the graph as the complete fetch specification — any attribute not in the graph is forced to LAZY. In practice, loadgraph is safer because it does not accidentally defer associations that were explicitly mapped as EAGER for good reason.
Conclusion
LAZY as the default entity mapping position is correct. The mistake is treating it as a complete strategy. LAZY defers the decision about when to load associations — it does not make that decision for you. For each endpoint that accesses a lazy association, the right fetch strategy is the one that loads exactly what is needed in the minimum number of round-trips: JOIN FETCH or EntityGraph for predictable to-one loads, BatchSize for unpredictable or mixed access patterns, and the ID-then-hydrate pattern whenever pagination meets a collection fetch. The Cartesian product from joining a collection with a pagination limit is the single most common Hibernate performance bug in production; avoiding it is a matter of recognising the pattern and splitting the query in two.