Fetch Types: EAGER vs LAZY Loading
Introduction
Every relationship in JPA has a fetch type: either EAGER (load immediately with the parent) or LAZY (load only when accessed). The defaults are counterintuitive, and getting fetch types wrong is one of the top causes of performance problems and LazyInitializationException in JPA applications.
Default Fetch Types
| Annotation | Default fetch type | Performance risk |
|---|---|---|
@ManyToOne | EAGER | Loads related entity on every query — can be expensive |
@OneToOne | EAGER | Same — loads profile/address on every customer load |
@OneToMany | LAZY | Correct default — collections are only loaded when needed |
@ManyToMany | LAZY | Correct default |
The defaults for @ManyToOne and @OneToOne are EAGER — a poor choice that the JPA spec made for historical reasons. Always override them to LAZY:
// Correct — override defaults to LAZY
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private CustomerProfile profile;
How EAGER Loading Works
When you load an Order with EAGER fetch on customer:
// customer is EAGER
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "customer_id")
private Customer customer;
Loading 10 orders generates:
SELECT o.* FROM orders o -- 1 query
SELECT c.* FROM customers c WHERE c.id = ? -- 1 per order
SELECT c.* FROM customers c WHERE c.id = ? -- (10 total)
...
This is the N+1 problem — N queries for N orders’ customers. Even if you only care about order IDs, customers are always loaded.
How LAZY Loading Works
LAZY loading uses a proxy object — a Hibernate-generated subclass of the entity that intercepts field access:
// customer is LAZY
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
When you load an Order:
order.getCustomer()returns a proxy — an empty shell with only the id- No SQL is executed yet
- When you call
order.getCustomer().getName(), the proxy fires aSELECTto load the realCustomer
@Transactional
public void processOrder(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
// order.customer is a proxy — no SQL for customer yet
String name = order.getCustomer().getName();
// NOW the proxy fires: SELECT * FROM customers WHERE id = ?
// customer is loaded and the proxy delegates to the real Customer
}
LazyInitializationException
The most common JPA exception. It happens when you try to access a lazy relationship after the persistence context is closed (outside a transaction):
@Service
public class OrderService {
@Transactional
public Order findOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
} // transaction ends here — persistence context CLOSES — order is DETACHED
}
@RestController
public class OrderController {
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
Order order = orderService.findOrder(id);
// order is DETACHED — no persistence context
// LazyInitializationException:
// failed to lazily initialize a collection of role: Order.items
List<OrderItem> items = order.getItems();
return new OrderResponse(order, items);
}
}
Hibernate cannot load items because the Session is gone.
Solutions for LazyInitializationException
Solution 1: Load inside the transaction (recommended)
@Transactional
public OrderResponse findOrderWithItems(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
// Access items INSIDE the transaction
order.getItems().size(); // initialises the collection
return new OrderResponse(order, order.getItems());
}
Solution 2: JOIN FETCH in query
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findWithItems(@Param("id") Long id);
Single SQL query fetches order + items together — no lazy loading needed.
Solution 3: @EntityGraph
@EntityGraph(attributePaths = {"items", "items.product"})
Optional<Order> findById(Long id);
Hibernate builds a JOIN FETCH dynamically.
Solution 4: DTO projection (best for read operations)
@Query("SELECT new com.devopsmonk.OrderDto(o.id, o.status, i.quantity) " +
"FROM Order o JOIN o.items i WHERE o.id = :id")
List<OrderDto> findOrderDto(@Param("id") Long id);
No entity loaded — no lazy loading problem.
All these approaches are covered in depth in Articles 20 (Projections) and 26–27 (N+1 / Entity Graphs).
OpenEntityManagerInViewFilter — Avoid It
Spring Boot automatically configures spring.jpa.open-in-view=true, which keeps the persistence context open through the entire HTTP request (including view rendering). This prevents LazyInitializationException but causes:
- Database connections held for the full duration of the HTTP request
- Lazy queries fired unexpectedly during serialization (N+1 you cannot see)
- Business logic and presentation layer coupled through the persistence context
Turn it off in production:
spring.jpa.open-in-view=false
With this off, lazy loading only works inside @Transactional methods. You will get LazyInitializationException on any lazy access outside a transaction — which is the correct, predictable behaviour. Fix those with JOIN FETCH or DTO projections.
Fetch Types and Hibernate Joins
How Hibernate loads EAGER associations depends on whether they are on a @ManyToOne or @OneToMany:
@ManyToOne(EAGER)→ LEFT OUTER JOIN in the main SELECT (single query, but always joins)@OneToMany(EAGER)→ separate SELECT per parent (N+1 inherent)
For @OneToMany(EAGER), there is no way for Hibernate to avoid N+1 without an explicit JOIN FETCH. This is another reason to leave @OneToMany as LAZY (the default).
The Rule of Thumb
@ManyToOne → always LAZY (override the EAGER default)
@OneToOne → always LAZY (override the EAGER default)
@OneToMany → keep LAZY (default is already correct)
@ManyToMany → keep LAZY (default is already correct)
Load what you need when you need it — use JOIN FETCH, @EntityGraph, or DTO projections for the specific queries that require related data.
Checking Whether a Collection Is Initialised
Hibernate provides a utility to check lazy state without triggering a load:
import org.hibernate.Hibernate;
boolean loaded = Hibernate.isInitialized(order.getItems());
// true if items have been fetched, false if still a proxy/uninitialized collection
Use this in service code where you want to conditionally initialise:
if (!Hibernate.isInitialized(order.getItems())) {
// force initialise within the current transaction
Hibernate.initialize(order.getItems());
}
Key Takeaways
@ManyToOneand@OneToOnedefault toEAGER— always override toLAZY@OneToManyand@ManyToManydefault toLAZY— keep the default- LAZY loading uses proxy objects that fire a SELECT on first access
LazyInitializationExceptionmeans accessing a lazy association after the persistence context is closed- Fix it by loading inside the transaction, using JOIN FETCH, using @EntityGraph, or using DTO projections
- Turn off
spring.jpa.open-in-viewin production — it hides lazy loading issues and holds DB connections unnecessarily
What’s Next
Article 14 starts Part 4 — Inheritance Mapping. You’ll learn when to put related entities in one table (SINGLE_TABLE), separate tables with joins (JOINED), or completely independent tables (TABLE_PER_CLASS).