Ch. 8 · Spring Boot

LazyInitializationException in Spring Boot JPA: Causes and Fixes

Why Hibernate throws LazyInitializationException in Spring Boot, how open-in-view hides it, and how fetch joins, entity graphs and DTOs fix it.

~6 min readintermediateupdated Oct 6, 2026

“What is LazyInitializationException and how do you fix it?” is a standard Spring Data JPA interview question, and a frequent production bug. The tempting fixes (“make it EAGER”, “add @Transactional to the controller”, “turn on enable_lazy_load_no_trans”) all make the exception disappear while creating worse problems. Interviewers ask it to see whether you understand the persistence context’s lifetime and whether you design queries around what each use case needs.

Before you start

You should know how a JPA entity maps to a table, what @OneToMany and @ManyToOne mean, and that @Transactional on a service method opens a transaction. Examples target Spring Boot 3.x with Hibernate 6 and jakarta.persistence.

The short answer

Lazy associations are loaded on first access through a proxy or a persistent collection, which needs the Hibernate session (the persistence context) that loaded the entity to still be open. If you touch the association after the transaction ends, for example in a controller or during JSON serialization, Hibernate throws LazyInitializationException: could not initialize proxy - no Session. Fix it by loading what the use case needs inside the transaction: a JOIN FETCH query, an @EntityGraph, or a DTO projection. Spring Boot’s default spring.jpa.open-in-view=true hides the problem by keeping the session open for the whole request, at the cost of database access from the view layer.

How it works

@ManyToOne and @OneToOne are EAGER by default in JPA; @OneToMany and @ManyToMany are LAZY. Good practice is to make all of them LAZY and fetch explicitly.

@Entity
public class Order {
    @Id @GeneratedValue private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderLine> lines = new ArrayList<>();

    // getters
}
java

When you load an Order, Hibernate sets customer to a generated proxy subclass holding only the ID, and lines to a PersistentBag that has not been initialized. The first call to customer.getName() or lines.size() makes the proxy ask its session to run a query. The proxy holds a reference to the session that created it. When the transaction commits, Spring closes that session, and the proxy has nowhere to go.

@Service
class OrderService {
    @Transactional(readOnly = true)
    Order find(long id) {
        return orders.findById(id).orElseThrow();   // session open here
    }                                               // session closes at commit
}

// in a controller, outside the transaction:
Order order = service.find(42L);
order.getLines().size();   // LazyInitializationException (if open-in-view is off)
java

With spring.jpa.open-in-view=true (Boot’s default, with a startup warning), OpenEntityManagerInViewInterceptor binds an EntityManager for the entire web request, so this code works. Each lazy access in the controller or serializer then issues a query outside any transaction, often on a connection held for the full request.

Step-by-step walkthrough

Step 1: Turn off open-in-view

spring:
  jpa:
    open-in-view: false
yaml

This makes lazy access outside the service layer fail loudly in development, which is what you want. Every failure now marks a place where a use case needs data it did not ask for.

Step 2: Fetch what the use case needs with JOIN FETCH

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o from Order o join fetch o.customer left join fetch o.lines where o.id = :id")
    Optional<Order> findWithCustomerAndLines(@Param("id") long id);
}
java

One SQL statement loads the order, its customer and its lines. The service method calls this instead of findById for the “order details” use case.

Step 3: Or declare an entity graph

@EntityGraph keeps the derived query method and adds fetch instructions:

@EntityGraph(attributePaths = {"customer", "lines"})
Optional<Order> findDetailedById(long id);
java

Different use cases get different methods: a list screen might fetch only customer, a detail screen both.

Step 4: Return DTOs from the service

The cleanest boundary is to never let entities leave the transaction. Map to an immutable DTO while the session is open, or select straight into one:

public record OrderSummary(long id, String customerName, BigDecimal total) { }

@Query("""
       select new com.example.orders.OrderSummary(o.id, c.name, o.total)
       from Order o join o.customer c
       where o.status = :status
       """)
List<OrderSummary> summaries(@Param("status") OrderStatus status);
java

DTOs also stop Jackson from walking lazy associations during serialization, which is the other classic source of this exception and of Jackson infinite recursion.

Worked scenario

A team returns entities directly from a REST controller. With open-in-view on, GET /orders?status=OPEN works. A performance review finds the endpoint runs 201 queries for 100 orders: one for the list, then one per order for customer and one per order for lines, triggered by Jackson during serialization. This is the N+1 problem, invisible because open-in-view made lazy loading “just work”.

They disable open-in-view and the endpoint immediately fails:

org.hibernate.LazyInitializationException: could not initialize proxy [com.example.Customer#7] - no Session
Text

The fix replaces entity serialization with the OrderSummary DTO query from Step 4 for the list, and the @EntityGraph method for the detail endpoint. The list endpoint now runs one query. The controller no longer has database access hidden inside serialization, and connections return to the pool as soon as the service method finishes.

Common mistake

  • Switching to FetchType.EAGER. EAGER applies to every query that loads the entity, forever. It usually causes N+1 selects or huge joins elsewhere, and you cannot turn it off per query.
  • Putting @Transactional on controllers to keep the session open. That moves transaction boundaries into the web layer and holds connections during serialization.
  • Enabling hibernate.enable_lazy_load_no_trans. Each lazy load opens a new short session and connection: N+1 with extra overhead and no consistency.
  • Calling Hibernate.initialize() everywhere as a habit. It works but hides which data each use case needs; a fetch query states it explicitly.
  • Fetch-joining two List collections in one query, which Hibernate rejects with MultipleBagFetchException. Use a Set, or fetch the second collection in a separate query.

Verify the behavior

Count statements with Hibernate statistics in a @DataJpaTest, and prove the DTO works after the transaction ends:

@DataJpaTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
class OrderFetchTest {

    @Autowired OrderRepository orders;
    @Autowired EntityManager em;

    @Test
    void entityGraphLoadsLinesInOneQuery() {
        Statistics stats = em.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics();
        long id = persistOrderWithLines(3);
        em.clear();
        stats.clear();

        Order order = orders.findDetailedById(id).orElseThrow();
        em.detach(order);                          // simulate leaving the transaction

        assertThat(order.getLines()).hasSize(3);   // no exception: already initialized
        assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
    }
}
java

Replacing findDetailedById with findById makes the hasSize line throw LazyInitializationException, which proves the test exercises the boundary. Setting logging.level.org.hibernate.SQL=debug shows the generated SQL.

Follow-up questions

Why does Boot log a warning about open-in-view? Because it is enabled by default and silently allows database queries during view rendering. The warning nudges you to choose explicitly.

What is the difference between getReferenceById and findById? getReferenceById returns an uninitialized proxy without querying, useful for setting a foreign key; touching its fields outside a session throws the same exception.

How do batch fetching settings help? hibernate.default_batch_fetch_size=50 loads lazy associations for up to 50 parents in one IN query, turning N+1 into N/50+1. It is a good safety net, not a replacement for explicit fetching.

Does @Transactional(readOnly = true) affect lazy loading? No. It keeps the session open just as a read-write transaction does, while letting Hibernate skip dirty checking.

Interview exercise

An endpoint returns a Customer entity with lazy orders, and each order has lazy lines. With open-in-view disabled, it throws LazyInitializationException. The product manager wants the customer’s name and their five most recent order totals. Propose a fix.

Answer and reasoning

Do not return the entity or change fetch types. Write a query for exactly that view: a DTO record such as CustomerOverview(String name, List<OrderTotal> recent), filled by one query for the customer’s name and one paged query select new OrderTotal(o.id, o.total, o.createdAt) from Order o where o.customer.id = :id order by o.createdAt desc with Pageable limited to 5. Both run inside a @Transactional(readOnly = true) service method, and the controller serializes plain records. The reasoning: the exception showed that the response needed data the service never loaded, so make the service load it explicitly and stop leaking managed entities, rather than widening the session lifetime.

Continue learning

More in Spring Boot

read ✓Spring Boot · mid

Spring JPA N-Plus-One Query Diagnosis

Detect and fix JPA N+1 selects in Spring Boot with fetch joins, @EntityGraph, batch fetching and DTO queries, then prove it with a test.

~6 min readread →
read ✓Spring Boot · mid

Spring JPA Optimistic Locking and Conflicts

How JPA @Version prevents lost updates in Spring Boot, what ObjectOptimisticLockingFailureException means, and when to retry or return 409.

~6 min readread →
esc