The LazyInitializationException Problem
You've deployed your Spring Boot service, everything works in development, and then production logs fill with LazyInitializationException errors. The stack trace points to a line where you're accessing a lazy-loaded collection, but the Hibernate session is already closed. Fixing LazyInitializationException in Java & Spring Boot requires understanding when and how Hibernate loads relationships, then applying the right strategy for your use case.
This exception is one of the most common Hibernate gotchas we encounter across services handling e-commerce orders, CRM contacts, and travel bookings. The root cause is simple: a lazy collection is accessed outside the transaction scope where it was loaded. The session is gone, the database connection is closed, and Hibernate cannot fetch the missing data anymore.
Understanding the Root Cause
By default, Hibernate uses lazy loading for relationships. When you load an entity, related collections are not immediately fetched from the database. Instead, Hibernate creates a proxy that defers the actual query until you access the collection. If that access happens after the session closes—typically after the service method returns—you hit the exception.
Consider this common scenario:
@Entity
public class Order {
@Id
private Long id;
private String orderNumber;
@OneToMany(mappedBy = "order")
private List<OrderItem> items; // Lazy by default
}
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepository.findById(id).orElse(null);
}
}
// In controller
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
Order order = orderService.getOrder(id);
// At this point, order.items is a proxy, not loaded yet
return order; // Jackson tries to serialize items → LazyInitializationException
}
}When Jackson serializes the response, it accesses items, but the transaction (and session) ended when getOrder returned. Hibernate cannot load the collection anymore.
Fetch What You Need in the Query (Join Fetch)
The first and most efficient solution is to load relationships explicitly in the query itself using JOIN FETCH. This tells Hibernate exactly what you need upfront, fetching it in a single query or minimal queries instead of lazy-loading later.
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
}
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepository.findByIdWithItems(id).orElse(null);
}
}Use LEFT JOIN FETCH when the collection might be empty; use INNER JOIN FETCH only if items must exist. The data is now loaded inside the transaction. When the response is serialized, the items are already in memory.
For more complex scenarios with multiple relationships, craft your query to include only what the API endpoint needs:
@Query("""
SELECT DISTINCT o FROM Order o
LEFT JOIN FETCH o.items i
LEFT JOIN FETCH o.customer c
WHERE o.id = :id
""")
Optional<Order> findByIdWithDetails(@Param("id") Long id);This approach is ideal for specific endpoints that always need the same relationships. It's explicit, performant, and keeps data loading inside the transaction boundary.
Map to DTOs Inside the Transaction
When you need different subsets of data for different endpoints, mapping to Data Transfer Objects (DTOs) inside the service layer is cleaner and more flexible than loading full entities and letting serialization handle it.
public class OrderDTO {
private Long id;
private String orderNumber;
private List<OrderItemDTO> items;
public OrderDTO(Order order) {
this.id = order.getId();
this.orderNumber = order.getOrderNumber();
this.items = order.getItems().stream()
.map(OrderItemDTO::new)
.collect(Collectors.toList());
}
}
@Service
public class OrderService {
@Transactional(readOnly = true)
public OrderDTO getOrderDTO(Long id) {
Order order = orderRepository.findByIdWithItems(id)
.orElseThrow(() -> new EntityNotFoundException("Order not found"));
return new OrderDTO(order); // Mapping happens inside transaction
}
}The key is that the constructor accesses order.getItems() while the session is still open. By the time the method returns, you have a fully populated DTO with no lazy proxies. The controller returns the DTO—no entities, no surprises.
This pattern scales well across services. You can have multiple DTOs for the same entity, each loading exactly the relationships it needs via different queries and constructors.
Do Not Enable Open-in-View to Hide It
Spring Boot and Spring Data provide a configuration called spring.jpa.open-in-view (or hibernateProperties.hibernate.enable_lazy_load_no_trans) that keeps the session open throughout the entire HTTP request, even after the service method returns. It silences the exception, but it masks the real problem.
// In application.properties
spring.jpa.open-in-view=true // DON'T do this
// Or in configuration class
@Configuration
public class HibernateConfig {
// This also enables it, avoid if possible
}Why not use this? Several reasons:
- Hidden N+1 queries. Lazy loading happens silently in your controller or view layer, firing database queries for every lazy access. Performance degrades without obvious warnings.
- Session held longer. Connections stay allocated for the full HTTP request, reducing database connection pool availability under load.
- Unpredictable data consistency. Multiple lazy loads in the controller might read inconsistent snapshots if other threads modify data mid-request.
- False sense of security. It works in dev but your code is fragile. If someone removes the config or refactors the serialization logic, everything breaks.
We've seen e-commerce services grind to a halt in production with this enabled because cascading lazy loads during JSON serialization consumed all database connections. It's a band-aid, not a fix.
Avoid toString() on Lazy Relationships
Many developers unwittingly trigger lazy loading in logging or debugging by calling toString() on entities with lazy collections. Lombok's @ToString annotation is a common culprit:
@Entity
@ToString // Generates toString() that includes all fields
public class Order {
@Id
private Long id;
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
}
// In service
@Transactional
public void processOrder(Order order) {
// Some logic here
} // Transaction ends
// Later, in a log statement outside transaction
logger.info("Processed: " + order); // toString() accesses items → LazyInitializationException
logger.info("Order: {}", order); // Same issueExclude lazy collections from toString():
@Entity
@ToString(exclude = {"items"}) // Exclude lazy collections
public class Order {
@Id
private Long id;
private String orderNumber;
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
}Better yet, avoid logging entities directly. Log IDs or DTOs instead:
logger.info("Processed order: {}", order.getId());
logger.info("Processed: {}", new OrderSummary(order.getId(), order.getOrderNumber()));This keeps your code explicit and ensures you're not accidentally triggering lazy loads in places you don't expect them.
Common Mistakes
- Using JOIN without FETCH.
JOIN o.itemsfilters results but doesn't load the collection. UseJOIN FETCHto actually populate it. - Returning entities from service to controller. Services should return DTOs. Serialization happens outside the transaction, so any lazy access fails.
- Accessing lazy collections in scheduled tasks. If a
@Scheduledmethod calls a service that returns an entity, lazy access outside the transaction will fail. Use DTOs or eager queries. - Forgetting that Jackson traverses the full object graph. Even if you don't explicitly access a collection, JSON serialization does, triggering lazy loading.
- Enabling open-in-view in production. It hides the exception but creates worse problems: N+1 queries, held connections, and unpredictable behavior.
Key Takeaways
- Always load lazy relationships inside the transaction using
JOIN FETCHor explicit queries that eagerly load what you need. - Map entities to DTOs inside the service layer, before the transaction ends. This gives you control over what data leaves the transaction boundary.
- Never rely on
open-in-view=trueto silence exceptions. It masks problems and creates performance issues under load. - Exclude lazy collections from entity
toString()methods and avoid logging entities directly. - Be explicit about what relationships each query needs. Different endpoints may call different repository methods, each with its own
JOIN FETCHstrategy.






Comments
Loading comments…