“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
}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)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: falseThis 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);
}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);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);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 SessionThe 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
@Transactionalon 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
Listcollections in one query, which Hibernate rejects withMultipleBagFetchException. Use aSet, 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);
}
}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.