Lazy Loading, Eager Loading, and the N+1 Query Problem
How Hibernate fetch strategies work, why N+1 query bugs happen in production, and the concrete fixes: JOIN FETCH, entity graphs, and batch fetching.
Lazy Loading, Eager Loading, and N+1
If there is one Hibernate bug class that has quietly degraded more production APIs than any other, it's the N+1 query problem. It doesn't show up in unit tests against an in-memory dataset of three rows. It shows up three months later when a customer list endpoint that used to take 40ms starts taking 4 seconds, because someone added a new field that walks a lazy association inside a loop. This guide covers how fetch strategies actually work under the hood and the concrete, production-tested fixes.
1. FetchType.LAZY vs FetchType.EAGER
FetchType controls when an association is loaded relative to its owning entity.
LAZY: the association is loaded on first access, via a proxy or an uninitialized collection wrapper. Nothing is fetched until you actually call a method on it.EAGER: the association is loaded immediately, in the same query (via aJOIN) or a follow-up query, whenever the owning entity is loaded — whether you need it or not.
Default fetch types by association
| Annotation | Spec default | Practical recommendation |
|---|---|---|
@ManyToOne | EAGER | Override to LAZY explicitly, always |
@OneToOne | EAGER | Override to LAZY explicitly, always |
@OneToMany | LAZY | Keep as LAZY |
@ManyToMany | LAZY | Keep as LAZY |
The JPA spec defaults @ManyToOne and @OneToOne to EAGER — the opposite of what you want in almost every production system. Every @ManyToOne/@OneToOne mapping should be written with fetch = FetchType.LAZY explicitly. Leaving the default in place is one of the most common sources of accidental deep object graphs being pulled into memory.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<LineItem> lineItems = new ArrayList<>();
}How LAZY actually works
For @ManyToOne/@OneToOne, Hibernate returns a bytecode-generated proxy — a subclass of your entity with all fields empty except the ID, which only hits the database when a non-identifier method is called on it. For @OneToMany/@ManyToMany collections, Hibernate wraps them in PersistentBag, PersistentSet, or PersistentList — collection implementations that trigger a SELECT on first iteration, size(), or similar access.
Order order = entityManager.getReference(Order.class, 1L);
// no SQL yet — order is a proxy with only the ID populated
Long customerId = order.getId();
// still no SQL — the ID was passed in, no DB hit needed
String status = order.getStatus().name();
// SQL fires here — first real field access initializes the proxy2. Cascade Types
Cascading propagates entity state operations (persist, merge, remove, etc.) from a parent entity to its associated children.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<LineItem> lineItems = new ArrayList<>();| Cascade type | Effect |
|---|---|
PERSIST | Saving the parent also saves new children |
MERGE | Merging the parent also merges children |
REMOVE | Deleting the parent also deletes children |
REFRESH | Refreshing the parent also refreshes children from the DB |
DETACH | Detaching the parent also detaches children |
ALL | All of the above |
orphanRemoval = true is a related but distinct setting: it deletes a child row when it's removed from the collection, even if the parent itself isn't deleted.
CascadeType.ALL + @ManyToOne is a common production landmine. Cascading REMOVE from a child to a shared parent (e.g., cascading from LineItem up to Product) can delete a Product that's referenced by dozens of other orders. Only cascade REMOVE/ALL on associations where the child's lifecycle is truly owned by the parent — the classic "composition" relationship (Order → LineItem), never a "reference" relationship (LineItem → Product).
3. How the N+1 Problem Happens
The N+1 problem occurs when you fetch a list of N parent entities with one query, then trigger N additional queries — one per parent — to lazily load an association for each.
// Looks innocent, isn't
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
System.out.println(order.getCustomer().getName()); // N queries — one per order!
}
// Total: 1 + N queries, where N = orders.size()For 20 orders on a page, that's 21 round trips to the database instead of 1 or 2. At scale, with connection pool contention and network latency added to each round trip, this alone can be the difference between a 30ms and a 3-second response.
N+1 isn't unique to @ManyToOne — it happens just as easily with @OneToMany collections accessed in a loop, and with derived/@Query methods that return a list of entities whose lazy associations get walked downstream (e.g., in a serialization layer or a mapper).
Detecting N+1 in practice
# application.yml — log every SQL statement Hibernate issues
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
generate_statistics: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.stat: DEBUGIn production, don't rely on show-sql — use a SQL-counting library like datasource-proxy or p6spy, or Hibernate's built-in Statistics API, wired into an integration test assertion:
@Test
void listOrders_shouldNotTriggerNPlusOne() {
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
List<OrderSummary> result = orderService.listRecentOrders();
assertThat(stats.getQueryExecutionCount()).isLessThanOrEqualTo(2);
}A good habit: assert query counts in integration tests for any endpoint returning a list. It turns N+1 regressions into a CI failure instead of a production incident three sprints later.
4. Fix #1: JOIN FETCH
The most direct fix — pull the association into the same query using JPQL's JOIN FETCH.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
}This generates a single SQL JOIN:
SELECT o.*, c.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = ?JOIN FETCH on a @OneToMany collection combined with pagination silently breaks. Hibernate cannot apply LIMIT/OFFSET at the SQL level when a collection join would multiply row counts — it fetches the entire result set into memory and paginates in Java, logging a warning ("firstResult/maxResults specified with collection fetch; applying in memory"). Never combine JOIN FETCH on a to-many association with Pageable. Use entity graphs or batch fetching instead for paginated queries.
// DANGEROUS: pagination silently happens in memory, not in SQL
@Query("SELECT o FROM Order o JOIN FETCH o.lineItems")
Page<Order> findAllWithLineItems(Pageable pageable);5. Fix #2: Entity Graphs
@EntityGraph lets you declare fetch plans without hand-writing JPQL, and it composes cleanly with derived query methods.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"customer", "lineItems"})
List<Order> findByStatus(OrderStatus status);
@EntityGraph(attributePaths = {"customer", "lineItems.product"})
Optional<Order> findWithFullDetailById(Long id);
}Or defined declaratively on the entity itself and referenced by name:
@Entity
@NamedEntityGraph(
name = "Order.withCustomerAndItems",
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("lineItems")
}
)
public class Order { /* ... */ }@EntityGraph(value = "Order.withCustomerAndItems", type = EntityGraph.EntityGraphType.LOAD)
List<Order> findByStatus(OrderStatus status);EntityGraphType | Behavior |
|---|---|
FETCH | Attributes in the graph are EAGER; everything else falls back to LAZY, ignoring the entity's normal mapping default |
LOAD | Attributes in the graph are EAGER; everything else uses its mapped fetch type (respects your @ManyToOne(fetch = LAZY) elsewhere) |
EntityGraphType.LOAD (the default) is almost always what you want — it only affects the attributes you explicitly list, leaving your normal LAZY mappings intact everywhere else in the entity.
6. Fix #3: Batch Fetching
For cases where you can't easily rewrite the query (deeply nested lazy chains, dynamic access patterns), batch fetching collapses N individual SELECTs into a handful of SELECT ... WHERE id IN (?, ?, ?, ...) queries.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 25Or per-association:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
@BatchSize(size = 25)
private Customer customer;
@OneToMany(mappedBy = "order")
@BatchSize(size = 25)
private List<LineItem> lineItems;With batch size 25, loading 100 orders and then walking order.getCustomer() for each produces 4 queries (100 / 25) instead of 100:
SELECT * FROM customers WHERE id IN (?, ?, ?, ..., ?); -- up to 25 ids at a timeComparing the three fixes
| Fix | Query count | Pagination-safe | Best for |
|---|---|---|---|
JOIN FETCH | 1 | No (collections) | Single-entity or @ManyToOne-only queries |
@EntityGraph | 1-2 | No (collections) | Declarative, reusable fetch plans on repository methods |
Batch fetching (default_batch_fetch_size / @BatchSize) | Ceil(N / batchSize) | Yes | Paginated lists, deeply nested lazy graphs |
In real systems, all three coexist: JOIN FETCH or entity graphs for the primary association you always need, and a global default_batch_fetch_size as a safety net for everything else you didn't explicitly plan for.
7. LazyInitializationException and Open Session in View
Accessing a lazy association after the persistence context (Hibernate Session) has closed throws LazyInitializationException.
@Service
public class OrderService {
private final OrderRepository orderRepository;
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
// transaction, and the Session, close when this method returns
}
}
// In a controller or mapper, called later:
Order order = orderService.getOrder(1L);
order.getLineItems().size(); // LazyInitializationException — session is closedSpring Boot ships with Open Session in View (OSIV) enabled by default, which keeps the Hibernate session open for the entire HTTP request — masking this class of bug by letting lazy loading happen even in the view/serialization layer.
OSIV is a trap, not a safety net. It hides N+1 problems during development (everything "just works") and moves the cost of lazy initialization into the HTTP response-rendering phase, holding a DB connection open for the full request lifecycle — including template rendering or JSON serialization time. Most staff-level Spring Boot teams disable it (spring.jpa.open-in-view: false) and instead fetch everything needed inside an explicit @Transactional service method, using JOIN FETCH/entity graphs/DTO projections deliberately.
spring:
jpa:
open-in-view: falseWith OSIV off, the fix is architectural discipline: service-layer methods must return fully-initialized data (either fully fetched entities or DTOs), never lazy proxies for the controller layer to poke at later.
@Service
public class OrderService {
@Transactional(readOnly = true)
public OrderDetailDto getOrderDetail(Long id) {
Order order = orderRepository.findWithFullDetailById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
return OrderDetailDto.from(order); // mapped to DTO while session is open
}
}8. Production Observations
- Default every
@ManyToOne/@OneToOnetoLAZY— treat the JPA spec'sEAGERdefault as a bug you have to opt out of on every mapping. - Never trust "it worked in the test" — small test datasets hide N+1 bugs completely. Test with realistic row counts or assert query counts explicitly.
open-in-view: falseis the right default for services beyond a prototype — it forces fetch strategy decisions to be explicit and visible in code review.- Combine strategies:
JOIN FETCH/@EntityGraphfor the "always needed" association,default_batch_fetch_sizeas the safety net for everything else. - Watch out for mappers/serializers: a Jackson
@JsonIgnorePropertiesor MapStruct mapper that touches a lazy field outside the transaction is a very common source ofLazyInitializationExceptionin real codebases.
Key takeaways
@ManyToOne/@OneToOnedefault toEAGERper spec — always override toLAZYexplicitly.- N+1 happens when a list query is followed by N per-row queries for a lazily-loaded association accessed in a loop.
JOIN FETCHand@EntityGraphcollapse N+1 into 1-2 queries but break in-database pagination on to-many associations.- Batch fetching (
default_batch_fetch_size,@BatchSize) is the pagination-safe fix — it turns N queries intoN / batchSizequeries. LazyInitializationExceptionmeans you accessed an association after the persistence context closed; the real fix is fetching what you need inside the transaction, not blindly enabling OSIV.- Open Session in View hides N+1 bugs during development and holds DB connections open longer than necessary — disable it in production-grade services.
- Assert SQL query counts in integration tests for list-returning endpoints; it's the cheapest regression guard against N+1 creeping back in.
Interview Questions
- What is the difference between
FetchType.LAZYandFetchType.EAGER? - Why does the JPA spec default
@ManyToOnetoEAGER, and why is that usually wrong for production code? - Walk through exactly how the N+1 query problem occurs when iterating a list of entities and accessing a lazy association.
- How would you detect an N+1 problem in an existing codebase without a profiler?
- What SQL does
JOIN FETCHgenerate, and why does it break with pagination on collection associations? - What is
@EntityGraph, and what's the difference betweenEntityGraphType.FETCHandEntityGraphType.LOAD? - How does batch fetching (
default_batch_fetch_size) reduce query count, and why is it pagination-safe whenJOIN FETCHisn't? - What causes a
LazyInitializationException, and what are the two ways to avoid it? - What is Open Session in View, and why do many senior engineers recommend disabling it?
- What's the difference between
CascadeType.REMOVEandorphanRemoval = true? - Why is
CascadeType.ALLdangerous on a@ManyToOneassociation? - Given a REST endpoint that got slow after adding a new field to its response DTO, how would you diagnose whether N+1 is the cause?
- How do proxies work for
@ManyToOnelazy associations versusPersistentBag/PersistentSetfor collections?