Lazy vs Eager Loading in JPA — What Gets Loaded and When
by Eric Hanson, Backend Developer at Clean Systems Consulting
The two fetch strategies
Eager loading retrieves the associated data in the same database operation as the parent entity. When you load an Order, eager associations are loaded immediately — whether you access them or not.
Lazy loading defers retrieval until the association is first accessed. When you load an Order, lazy associations are proxies. The database query fires when your code calls order.getLineItems() or order.getUser().
The default fetch types by association:
| Association | Default |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
The EAGER default for @ManyToOne and @OneToOne is a legacy decision from JPA 1.0 that predates widespread understanding of its performance consequences. In most production codebases, it's wrong.
What eager loading actually does
An entity with eager associations generates a JOIN query:
@Entity
public class Order {
@Id private Long id;
@ManyToOne(fetch = FetchType.EAGER) // the default
private User user;
@OneToOne(fetch = FetchType.EAGER) // the default
private ShippingAddress shippingAddress;
}
Loading this entity:
Order order = orderRepository.findById(1L).orElseThrow();
Generates:
SELECT o.*, u.*, sa.*
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN shipping_addresses sa ON sa.order_id = o.id
WHERE o.id = 1
Three tables in one query. Every load of Order includes user and shipping address data, regardless of whether either is ever accessed in the current request. This is the core problem: eager loading is unconditional.
For a query that returns 1,000 orders:
List<Order> orders = orderRepository.findAll();
With eager @ManyToOne to User, Hibernate generates a JOIN that returns 1,000 rows, each carrying the full user data — even for a method that only displays order IDs and statuses.
What lazy loading actually does
With lazy associations, Hibernate creates a proxy object that intercepts property access:
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
private User user;
}
Order order = orderRepository.findById(1L).orElseThrow();
// SQL: SELECT * FROM orders WHERE id = 1
// user field contains a proxy — no SQL yet
String email = order.getUser().getEmail();
// SQL: SELECT * FROM users WHERE id = ?
// Triggered by .getEmail() which forces proxy initialization
The proxy intercepts the first method call on order.getUser(), fires the database query, populates the proxy with the actual data, and then delegates the method call to the loaded entity. Subsequent calls to order.getUser() return the loaded entity without additional queries.
The LazyInitializationException
Lazy loading requires an active Hibernate session. When you access a lazy association outside a transaction — after the session is closed — Hibernate cannot load the data:
@Service
public class OrderService {
@Transactional
public Order findOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
// transaction ends when method returns — session closes
}
}
// Controller
Order order = orderService.findOrder(1L);
order.getUser().getEmail(); // LazyInitializationException — session is closed
LazyInitializationException is the most common JPA error in Spring Boot applications. Three responses:
Fix 1: Load within the transaction. Use JOIN FETCH or @EntityGraph to load the association before the session closes:
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
Optional<Order> findByIdWithUser(@Param("id") Long id);
Fix 2: Keep the session open (anti-pattern). spring.jpa.open-in-view=true (the Spring Boot default!) extends the Hibernate session through the view rendering phase, allowing lazy loading in templates and serializers. This works but:
- Holds a database connection through the entire request, including view rendering time
- Masks the actual data loading that's happening — lazy loads in templates are invisible
- Hides N+1 problems until load testing
Set spring.jpa.open-in-view=false and handle lazy loading explicitly. The warning Spring Boot logs at startup when OIDP is detected is telling you something real.
Fix 3: DTO projection. Load only what's needed at query time, returning a DTO rather than an entity with lazy associations:
public record OrderSummary(Long id, OrderStatus status, String userEmail) {}
@Query("SELECT new com.example.OrderSummary(o.id, o.status, u.email) " +
"FROM Order o JOIN o.user u WHERE o.id = :id")
Optional<OrderSummary> findSummaryById(@Param("id") Long id);
No entity, no proxy, no lazy loading, no LazyInitializationException possible.
The correct default: lazy everything, explicit when needed
The production rule: all associations LAZY, eager loading only when explicitly requested per query.
@Entity
public class Order {
@Id private Long id;
@ManyToOne(fetch = FetchType.LAZY) // override the default
private User user;
@OneToOne(fetch = FetchType.LAZY) // override the default
private ShippingAddress shippingAddress;
@OneToMany(mappedBy = "order") // already LAZY
private List<LineItem> lineItems;
}
Now every query for Order is just:
SELECT * FROM orders WHERE ...
No joins unless explicitly requested. Queries that need the user add JOIN FETCH:
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
List<Order> findByStatusWithUser(@Param("status") OrderStatus status);
Queries that don't need the user don't pay for it.
How Hibernate implements lazy loading — the proxy
For @ManyToOne and @OneToOne, Hibernate creates a CGLIB proxy — a subclass of User that intercepts all method calls. The proxy contains only the entity's ID. On first method call, it fires the query and delegates:
User user = order.getUser(); // returns a proxy
user instanceof User; // true — proxy is a User subclass
user.getId(); // may return without a query (ID is stored in the proxy)
user.getEmail(); // fires SELECT * FROM users WHERE id = ?
user.getId() sometimes doesn't trigger a query — if Hibernate knows the ID (it does, from the foreign key column), it can answer the ID question from the proxy without loading the entity. This is the "proxy optimization" — accessing only the ID of an eager or lazy @ManyToOne doesn't always require loading the associated entity.
For @OneToMany and @ManyToMany, Hibernate uses a PersistentBag or PersistentList wrapper that loads the collection on first access.
Type-checking lazy proxies
A common bug with lazy proxies: instanceof and casting with lazy associations:
Entity entity = entityRepository.findById(id).orElseThrow();
AbstractEntity base = entity.getRelated(); // lazy proxy
if (base instanceof ConcreteSubclass) { // false — proxy is not ConcreteSubclass
ConcreteSubclass concrete = (ConcreteSubclass) base; // ClassCastException
}
The proxy is a subclass of the declared type (AbstractEntity), not of the actual runtime type (ConcreteSubclass). Hibernate provides Hibernate.unproxy() to get the actual entity:
AbstractEntity unproxied = Hibernate.unproxy(base, AbstractEntity.class);
if (unproxied instanceof ConcreteSubclass concrete) {
// works correctly
}
Or force initialization before type checking:
Hibernate.initialize(base); // forces proxy initialization
if (base instanceof ConcreteSubclass) { // now works
Collection loading behavior
@OneToMany with lazy loading defers collection load until the collection is first accessed. But the behavior of the loaded collection is a source of bugs:
// Both of these trigger the collection load
int size = order.getLineItems().size();
boolean contains = order.getLineItems().contains(item);
// This triggers the load then executes a new query — does not use the loaded collection
long count = lineItemRepository.countByOrderId(order.getId());
// This ALSO triggers a new query, bypassing the loaded collection
List<LineItem> filtered = lineItemRepository.findByOrderIdAndStatus(
order.getId(), LineItemStatus.BACKORDERED);
Once a lazy collection is initialized, Hibernate holds it in the persistence context. Methods that call back to the repository against the same data bypass the persistence context and issue new queries. For consistent behavior, use the initialized collection for in-memory operations and the repository for new queries — don't mix them.
The EAGER trap in inheritance hierarchies
With inheritance mapped as SINGLE_TABLE or JOINED, eager associations on superclass entities apply to all subclass queries. An eager @ManyToOne on BaseEntity means every query for ConcreteEntityA, ConcreteEntityB, and ConcreteEntityC eagerly loads that association:
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class BaseEntity {
@ManyToOne(fetch = FetchType.EAGER) // affects ALL subclass queries
private Organization organization;
}
In inheritance hierarchies especially, lazy defaults and per-query eager loading prevent unintended joins across the entire hierarchy.
The diagnostic: check what's actually executing
Enable SQL logging in development and exercise the code paths that matter. The query log shows whether associations are loading when you didn't ask for them (eager loading you don't need) or generating N queries (lazy loading without batch fetching):
logging:
level:
org.hibernate.SQL: DEBUG
A correctly configured entity model shows:
- One query per data load path, no automatic JOINs you didn't request
- No repeated queries for the same association across a collection
JOIN FETCHor batch fetch queries where associations are actually needed
The goal is queries that match intent: load what you need, when you need it, at the level of granularity the use case requires.