LazyInitializationException, and the Four Ways to Fix It

3 min read

  • JPA
  • Spring Boot
  • Performance

failed to lazily initialize a collection of role: com.example.Order.items, could not initialize proxy - no Session is probably the single most common Hibernate error, and every team eventually picks a fix. Picking the wrong one turns a crash into a performance problem, which is harder to notice and harder to undo.

Why it happens

A @OneToMany or @ManyToOne(fetch = LAZY) association is not loaded with its parent. Hibernate substitutes a proxy, and the proxy loads the real data the first time you touch it. That load needs an open session, and the session closes when the transaction ends.

@Transactional(readOnly = true)
public Order find(Long id) {
    return orderRepository.findById(id).orElseThrow();
}

// in the controller, transaction already over
order.getItems().size(); // boom

The object is still there, the proxy is still there, but the connection that could fill it in is gone. This is the detached state doing exactly what it is defined to do.

Fix 1: fetch what you need in the query

The right default. Ask for the association explicitly, in the same query, while the session is open.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = "items")
    Optional<Order> findWithItemsById(Long id);
}

Or with an explicit join fetch:

@Query("select distinct o from Order o join fetch o.items where o.id = :id")
Optional<Order> findWithItems(@Param("id") Long id);

One query, one round trip, no surprises later. The cost is that you need a separate repository method per fetch shape, which is a feature rather than a nuisance: the call site now says what it loads.

Note that join-fetching two collections at once produces a cartesian product. Fetch one collection per query and let the others load separately, or use @BatchSize.

Fix 2: project into a DTO

If the endpoint only needs a few fields, do not load entities at all.

@Query("""
    select new com.example.OrderSummary(o.id, o.total, count(i))
    from Order o join o.items i
    where o.customer.id = :customerId
    group by o.id, o.total
""")
List<OrderSummary> summaries(@Param("customerId") Long customerId);

Nothing is lazy because nothing is an entity. This is usually the fastest option and it pairs naturally with not returning entities from controllers in the first place.

Fix 3: initialise inside the transaction

Sometimes the shape is dynamic and a static fetch graph does not fit. Touching the association inside the transactional method is legitimate:

@Transactional(readOnly = true)
public OrderView load(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    return OrderView.from(order); // reads items here, session still open
}

The important part is that the mapping to the DTO happens inside the service, not in the controller. That is a good boundary to hold anyway.

Be aware this is still N+1 shaped if you do it in a loop over many parents. It is fine for a single aggregate, poor for a list. The distinction is the same one behind the N+1 query problem.

Fix 4: open session in view, and why it is the trap

Spring Boot enables spring.jpa.open-in-view=true by default. It keeps the session open for the whole request, so lazy loading in the controller or in the template simply works, and the exception disappears.

It disappears by turning every lazy access into an unplanned query at rendering time, outside any transaction you can see, holding a database connection for the entire request including the time spent serialising JSON. Under load that starves the connection pool, and the queries it generates are invisible in the service code.

spring.jpa.open-in-view=false

Turn it off on a new project, before there is code that depends on it. On an existing project, turn it off in a branch, run the tests, and treat every resulting LazyInitializationException as a genuine bug report telling you exactly where an unplanned query was hiding.

What about EAGER

Changing the mapping to fetch = EAGER fixes the exception globally and makes every single query that touches the entity load the association, whether it needs it or not. That includes queries where you wanted only the id. Leave associations lazy and decide per query.

The short version

Fetch deliberately in the query, project to DTOs when you only need fields, map inside the transaction when the shape is dynamic, and keep open-in-view off so the mistakes stay visible.