Skip to content

LazyInitializationException: could not initialize proxy — no Session — Root Cause and Fix

A deep dive into Spring Boot's LazyInitializationException, why it slips past code review and blows up in production, and how to fix it properly instead of slapping @Transactional on everything.

Gopi Gorantala
Gopi Gorantala
8 min read

1. The Error

You see this in your logs, usually from a serialization step (Jackson turning an entity into JSON) or a JSP/Thymeleaf template rendering a lazy collection:

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.acme.orders.domain.Order.lineItems: could not initialize proxy - no Session
	at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:625)
	at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:222)
	at org.hibernate.collection.spi.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:640)
	at org.hibernate.collection.spi.PersistentBag.iterator(PersistentBag.java:325)
	at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serializeContents(CollectionSerializer.java:141)
	at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:99)
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:735)
	at com.fasterxml.jackson.databind.ser.BeanSerializerBase.serializeFields(BeanSerializerBase.java:774)
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:180)
	at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.writeInternal(AbstractJackson2HttpMessageConverter.java:376)
	at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.writeWithMessageConverters(RequestResponseBodyMethodProcessor.java:293)
	...
Caused by: org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: ...: could not initialize proxy - no Session

Or, if it's a @ManyToOne/@OneToOne proxy instead of a collection, the message reads:

org.hibernate.LazyInitializationException: could not initialize proxy [com.acme.orders.domain.Customer#42] - no Session

Where this shows up: Spring Boot 3.2–3.5 (also fully reproducible on the newer Spring Boot 4.0/4.1 line, since the mechanics are unchanged), Java 17 or 21, spring-boot-starter-data-jpa with Hibernate ORM 6.x as the JPA provider, Jackson 2.x for JSON serialization. It's equally reproducible with plain spring-data-jpa + a controller returning an entity directly, with a batch job iterating lazy associations after a transaction closes, or with an async @Async method touching an entity handed to it from a transactional caller.

2. How to Reproduce It

Dependencies (pom.xml)

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Entities

@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    private String reference;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<LineItem> lineItems = new ArrayList<>();

    // getters/setters
}

@Entity
public class LineItem {

    @Id
    @GeneratedValue
    private Long id;

    private String sku;
    private int quantity;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    // getters/setters
}

Repository, service, controller

public interface OrderRepository extends JpaRepository<Order, Long> {}
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // No @Transactional here — this is the trigger
    public Order findOrder(Long id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found"));
    }
}
@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findOrder(id); // Jackson will try to serialize lineItems
    }
}

Config (application.yml)

spring:
  datasource:
    url: jdbc:h2:mem:testdb
  jpa:
    hibernate:
      ddl-auto: create-drop
    open-in-view: false   # <-- this is the trigger, and the correct production setting

Steps

  1. mvn spring-boot:run
  2. Seed an Order row with at least one LineItem (a CommandLineRunner or data.sql works).
  3. curl http://localhost:8080/orders/1
  4. You get a 500 with the LazyInitializationException above.

Environment-specific triggers:

  • With spring.jpa.open-in-view: true (Spring Boot's default, which logs a WARN on startup), this exact code will not fail — the Open Session in View filter keeps the Hibernate session open through view rendering/serialization, masking the bug. It resurfaces the moment open-in-view is disabled (which most teams do deliberately in production for connection-pool reasons — see Section 4).
  • It also reliably triggers when: the entity is returned from an async method (@Async) after the calling transaction has committed; the entity is cached (Caffeine/Redis via @Cacheable) and deserialized/served after the original session died; or a batch job reads entities in one transaction and processes lazy associations in a later step.

3. Why It Happens — Surface Level

lineItems is mapped FetchType.LAZY, so Hibernate doesn't load it from the DB when the Order is fetched — it substitutes a proxy (or, for collections, a PersistentBag/PersistentSet wrapper) that only knows how to fetch the real data using the Hibernate Session that loaded it.

OrderService.findOrder() has no @Transactional. JpaRepository.findById() is transactional on its own (Spring Data wraps repository methods in a transaction), but that transaction — and the Session bound to it — closes the instant findById() returns. By the time the Order object reaches OrderController and then Jackson's serializer, the session is gone. Jackson touches lineItems, the proxy tries to reach out to its (now closed) session, and Hibernate throws.

4. Why It Happens — Architectural / Deeper Level

Three things compound here, and understanding all three is what separates "add @Transactional" from actually fixing it.

Session-per-transaction, not session-per-entity. A Hibernate Session is a first-level cache and unit of work bound to a transaction (in Spring, via TransactionSynchronizationManager holding a SessionHolder keyed by the EntityManagerFactory). When @Transactional (or Spring Data's implicit transaction) commits, Spring's JpaTransactionManager closes the EntityManager/Session. The entity objects you got back still exist as plain Java objects, but any field that was lazy-proxied at load time is permanently orphaned from a live session. This isn't a bug — it's intentional: keeping sessions open indefinitely would leak connections and let entities grow stale.

Proxies are session-bound, not entity-bound. FetchType.LAZY on a @ManyToOne/@OneToOne gives you a bytecode-generated subclass proxy (via Hibernate's bytebuddy-based ProxyFactory or, on collections, a PersistentCollection wrapper). The proxy stores the entity's ID and a reference to the SessionImplementor that created it. First access to a non-ID getter triggers initialize(), which calls back into that stored session to issue a SELECT. If the session's isOpen() is false, withTemporarySessionIfNeeded() (Hibernate 6) checks whether "load-on-missing-session" behavior applies and, absent a temp-session strategy, throws LazyInitializationException instead of silently fetching through some ambient session — silently fetching would be worse, since it'd hide N+1 queries at serialization time with no correlation to a business transaction boundary.

Open Session in View is the reason this "used to work." Spring Boot's OSIV filter (OpenEntityManagerInViewInterceptor/Filter) opens an EntityManager at the start of the HTTP request and keeps it bound to the thread until the response is fully written — including during view rendering and JSON serialization. This is why so many teams "never see this error" until someone (rightly) sets spring.jpa.open-in-view=false. OSIV isn't really solving the N+1/lazy-loading problem; it's deferring the failure and, worse, deferring the query execution to the HTTP thread while holding a pooled connection the entire time. Under load, that's how you get HikariCP pool exhaustion during traffic spikes that have nothing to do with your actual query load — connections sit checked out for the full request/render/serialize lifecycle instead of just the DB work. That's exactly why Spring Boot logs a WARN by default and why most production configs disable it.

So the "no Session" error you're staring at isn't Hibernate being fragile — it's the visible symptom of a service layer that let a partially-loaded aggregate escape its transactional boundary. OSIV was hiding that architectural leak; disabling it (correctly) exposes it.

5. The Fix

Option A — Fetch what you need inside the transaction (best default)

@Service
 public class OrderService {

     private final OrderRepository orderRepository;

     public OrderService(OrderRepository orderRepository) {
         this.orderRepository = orderRepository;
     }

-    public Order findOrder(Long id) {
-        return orderRepository.findById(id)
-                .orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found"));
-    }
+    @Transactional(readOnly = true)
+    public Order findOrder(Long id) {
+        Order order = orderRepository.findById(id)
+                .orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found"));
+        order.getLineItems().size(); // force initialization inside the open session
+        return order;
+    }
 }

This works but is fragile — every new lazy field a future teammate adds needs the same manual .size()/.forEach() treatment. Use it only as a stopgap.

Option B — JOIN FETCH / entity graph (correct fix for read paths)

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o from Order o left join fetch o.lineItems where o.id = :id")
    Optional<Order> findByIdWithLineItems(@Param("id") Long id);
}
@Transactional(readOnly = true)
public Order findOrder(Long id) {
    return orderRepository.findByIdWithLineItems(id)
            .orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found"));
}

Use this when the caller genuinely needs the association. It's one round trip, no proxy games, and the fetched data is real, not a lazy stand-in.

Option C — DTO projection (correct fix for API responses)

public record OrderView(Long id, String reference, List<LineItemView> lineItems) {}
public record LineItemView(Long id, String sku, int quantity) {}
@Transactional(readOnly = true)
public OrderView findOrder(Long id) {
    Order order = orderRepository.findByIdWithLineItems(id)
            .orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found"));
    return new OrderView(
            order.getId(),
            order.getReference(),
            order.getLineItems().stream()
                    .map(li -> new LineItemView(li.getId(), li.getSku(), li.getQuantity()))
                    .toList());
}

Never let @Entity classes leave the service layer at all. This is the fix that actually scales across a team (see Section 6).

What not to do: re-enabling spring.jpa.open-in-view=true to make the symptom disappear. It doesn't fix anything; it just moves the connection-hold time and the query execution to an unpredictable point in the request lifecycle, and it will bite you again as an intermittent pool-exhaustion incident instead of a deterministic exception.

6. Best Alternative Approach / How to Re-Architect It

The real fix isn't a fetch strategy — it's never returning JPA entities across the service-layer boundary in the first place. Entities are your persistence model; controllers and callers outside the transactional boundary should only ever see DTOs/records that are fully materialized before the transaction closes.

// Port: what the outside world depends on
public interface OrderQueryService {
    OrderView getOrder(Long id);
}

// Adapter: owns the transaction and the Hibernate session lifecycle
@Service
class JpaOrderQueryService implements OrderQueryService {

    private final OrderRepository orderRepository;

    JpaOrderQueryService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public OrderView getOrder(Long id) {
        Order order = orderRepository.findByIdWithLineItems(id)
                .orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found"));
        return OrderMapper.toView(order); // mapping happens INSIDE the transaction
    }
}

@RestController
@RequestMapping("/orders")
class OrderController {

    private final OrderQueryService orderQueryService;

    OrderController(OrderQueryService orderQueryService) {
        this.orderQueryService = orderQueryService;
    }

    @GetMapping("/{id}")
    public OrderView getOrder(@PathVariable Long id) {
        return orderQueryService.getOrder(id); // plain record, no proxies, ever
    }
}

This is a hexagonal/ports-and-adapters framing: OrderQueryService is the port the web layer depends on; the JPA-backed implementation is the adapter that owns everything Hibernate-specific — session lifetime, fetch strategy, mapping. Nothing outside that adapter ever touches a PersistentBag or a lazy proxy, so LazyInitializationException becomes structurally impossible for that boundary, not just unlikely. It also decouples your API contract from your table structure, which pays off the next time you split LineItem into its own aggregate or move it to a different data store.

For write paths, the equivalent discipline is: load the aggregate, mutate it, let @Transactional flush and commit, and return only IDs or a freshly-built DTO — never the entity you just mutated.

7. How to Prevent It Long-Term

  • Keep spring.jpa.open-in-view=false everywhere, including local dev. If it only fails in staging/prod, your team will treat it as a staging-only quirk instead of fixing the root cause.
  • ArchUnit rule: forbid @Entity-annotated classes as return types or parameters of anything in a ..controller.. or ..web.. package.
@ArchTest
static final ArchRule entities_should_not_leak_to_web_layer =
    noClasses().that().resideInAPackage("..web..")
        .should().dependOnClassesThat().areAnnotatedWith(Entity.class);
  • Jackson safety net, not a fix: register Hibernate6Module (hibernate.disableTransientNullAccess, force-lazy-loading disabled) so that if an entity does leak, Jackson serializes lazy fields as null instead of throwing 500s in production — a guardrail for the leaks you haven't caught yet, not a substitute for Section 6.
  • Code review checklist item: any new @OneToMany/@ManyToOne with FetchType.LAZY must have a corresponding JOIN FETCH or entity-graph query if it's ever read on a hot path, documented in the PR description.
  • Load-test with realistic association depth before disabling OSIV in a codebase that has relied on it — the point is to surface every leak in a controlled load test, not in a customer-facing incident.
  • Static analysis: enable Hibernate's hibernate.javax.cache.missing_cache_strategy and log lazy-initialization warnings in CI integration tests by asserting on SQLStatementCountValidator (or similar query-count assertions) so an accidental N+1/late-fetch shows up as a failed test, not a runtime exception.

8. Key Takeaways

  • LazyInitializationException: could not initialize proxy - no Session means an entity with a lazy association escaped its Hibernate session — usually because it crossed the service-layer boundary after the owning @Transactional method returned.
  • Open Session in View doesn't fix this; it hides it by holding a DB connection for the entire request lifecycle, which trades a deterministic exception for nondeterministic connection-pool exhaustion under load.
  • JOIN FETCH / entity graphs solve the immediate query problem; they don't solve the architectural problem of entities leaking outside the transaction.
  • The durable fix is structural: map to DTOs inside the transactional method and never let @Entity classes cross into controllers, caches, or async handlers.
  • Enforce it with an ArchUnit rule, not tribal knowledge — this bug reappears every time a new engineer joins and adds a controller method that returns an entity directly.

Gopi Gorantala Twitter

Gopi is an Engineering Manager with over 14 years of extensive expertise in Java-based applications. He resides in Europe and specializes in designing and scaling high-performance applications.

Comments