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

AnnotationDefault fetch typePerformance risk
@ManyToOneEAGERLoads related entity on every query — can be expensive
@OneToOneEAGERSame — loads profile/address on every customer load
@OneToManyLAZYCorrect default — collections are only loaded when needed
@ManyToManyLAZYCorrect 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 a SELECT to load the real Customer
@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:

  1. Database connections held for the full duration of the HTTP request
  2. Lazy queries fired unexpectedly during serialization (N+1 you cannot see)
  3. 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

  • @ManyToOne and @OneToOne default to EAGER — always override to LAZY
  • @OneToMany and @ManyToMany default to LAZY — keep the default
  • LAZY loading uses proxy objects that fire a SELECT on first access
  • LazyInitializationException means 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-view in 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).