> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# LazyInitializationException: could not initialize proxy — no Session — Root Cause and Fix
- URL: https://www.ggorantala.dev/lazyinitializationexception-could-not-initialize-proxy-no-session-root-cause-and-fix/
- Published: 2026-07-05T09:55:32.000Z
- Updated: 2026-07-05T09:55:32.000Z
- Description: 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.
- Author: Gopi Gorantala

## 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:

```java
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:

```java
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`)

```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

```java
@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**

```java
public interface OrderRepository extends JpaRepository<Order, Long> {}
```

```java
@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"));
    }
}
```

```java
@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`)

```yaml
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)

```java
@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)

```java
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);
}
```

```java
@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)

```java
public record OrderView(Long id, String reference, List<LineItemView> lineItems) {}
public record LineItemView(Long id, String sku, int quantity) {}
```

```java
@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.

```java
// 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.

```java
@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.