Hibernate LazyInitialization — Proxy Has No Session
Hibernate LazyInitializationException: proxy touched after session closed.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓JPA entities and mappings
- ✓Spring @Transactional basics
- ✓SQL join fundamentals
- LazyInitializationException means code touched an unloaded lazy association after the Hibernate session closed
- The session ends at the service's transaction boundary; views, serializers, and later methods find detached proxies
- Fix by loading inside the transaction (JOIN FETCH, EntityGraph) and mapping to DTOs before returning
- Open-Session-in-View silences the error while exploding N+1 queries and holding connections — keep it off
- Never serialize entities to JSON; Jackson traverses proxies and throws or fires hundreds of queries
Think of a library that fetches books from the warehouse only when you ask (lazy loading). The librarian (session) goes home at 5 PM when your request ticket (transaction) closes. At 6 PM you ask for the sequel — no librarian, no book, just an apology slip (the exception). Open-Session-in-View keeps the librarian working overtime for every reader, which burns out the staff (connections) and empties the shelves one trip at a time (N+1).
The page renders halfway and dies: org.hibernate.LazyInitializationException: could not initialize proxy - no Session. The data exists — the query ran, the entity loaded. But one collection was marked lazy, the session closed at the service exit, and the template's innocent loop became a crime scene.
Lazy loading is Hibernate's promise to fetch associations only when touched. That promise needs an open session to run the follow-up SELECT. Service boundaries close sessions; views, serializers, and second methods touch proxies afterward. The proxy finds no session, keeps its promise by throwing, and your page dies rendering row three.
The quick fix — Open-Session-in-View — is the most expensive painkiller in JPA. It holds the session open through rendering, so lazy touches succeed by firing hundreds of surprise queries on connections held far too long. The exception vanishes; the N+1 explosion begins.
This article covers honest fixes: JOIN FETCH for single queries, EntityGraphs for reusable plans, and DTO projections that skip entities entirely. You will learn where transactional boundaries belong, why services must return DTOs, and how query-count tests keep the exception — and its N+1 shadow — gone for good.
Proxies and Detachment: Why Touches Explode After Close
Lazy associations load as proxies: placeholders holding the entity identity plus a reference to the session that can materialize them. Touching an uninitialized proxy fires a SELECT through that session — transparently while it is open, impossibly after it closes. The exception text says exactly this: could not initialize proxy because there is no Session.
Detachment is the normal trigger. Entities leaving a @Transactional method detach from the closed session; the objects look complete in the debugger (ids and loaded fields show), but unloaded collections are hollow proxies. The first iteration, size check, or serialization touch explodes — often far from the service, in templates or mappers.
The debugger lies by loading. Inspecting a lazy collection in a debug session initializes it through the open debug-session context, making the bug vanish under observation. Reproduce without the debugger: log collection sizes inside versus outside the transaction and watch the outside call throw.
Scope is the takeaway: lazy is a contract to load later within the same session, not a promise the data follows the object. Code receiving entities must know which associations are loaded; code beyond the boundary must receive DTOs. Proxies never cross architectural boundaries — data does.
JOIN FETCH: One Query, Fully Loaded, Per Use Case
JOIN FETCH loads the association in the same SELECT as the parent: one query, fully initialized collections, no follow-up SELECTs. The repository method above states its fetch plan plainly — orders with lines — and callers receive entities safe to traverse for the rest of the session. Distinct avoids duplicate parents from the join multiplication.
Scope each fetch to its use case. The report needs lines; the dropdown needs ids only; the detail page needs lines plus products. Three queries, three fetch plans, each loading exactly its screen. Shared findAll methods with fixed fetching either under-load (exceptions) or over-load (slowness) — per-use-case queries are the resolution.
Watch the two join-fetch pitfalls. Multiple collection fetch-joins in one query produce cartesian products (use one plus batch fetching for the rest), and fetch-joins with pagination in memory (Hibernate warns when the join breaks row-to-entity mapping). For paged collections, fetch the page of parents first, then batch-load their collections in a second query.
JOIN FETCH is the sharpest tool for single-purpose reads: explicit, local, and visible in the repository where reviewers can count queries. When three screens need three shapes, three methods beat one clever default.
EntityGraphs: Reusable Fetch Plans Without JPQL Surgery
EntityGraphs declare reusable fetch plans: named graphs on the entity plus loadgraph or fetchgraph hints at query time. Where JOIN FETCH hardcodes the plan into JPQL, graphs separate what to load from how to query — the same base query renders with different shapes per caller.
The two hint types differ in strictness. Fetchgraph loads only the named attributes (everything else stays lazy); loadgraph loads the named attributes plus defaults. Reports wanting exact columns choose fetchgraph; detail pages wanting the usual graph plus extras choose loadgraph. The distinction prevents both under-loading and over-loading from one mechanism.
Graphs compose with Spring Data through @EntityGraph annotations on repository methods, keeping the plan beside the query it serves. Reviewers see the fetch shape without parsing JPQL joins, and plan changes stay one annotation wide instead of rewriting queries.
Prefer graphs when several queries share shapes or when JPQL grows unreadable. Prefer JOIN FETCH for one-off plans where the query and its shape belong together. Both beat the alternatives — EAGER defaults that tax every query, and OSIV that taxes every page. Record per-query counts in code comments where fetch plans are non-obvious.
DTO Projections: Data Without Proxies
DTO projections skip entities entirely: the query selects exactly the columns the screen needs into an interface or record. No proxies, no sessions, no lazy anything — the result is plain data safe to serialize, cache, and cross boundaries. The projection above serves a list page with three columns and zero association machinery.
Projections are the fastest option by construction. Selecting 6 columns over 10,000 rows beats materializing 10,000 entities plus 40,000 line items by orders of magnitude — the month-end report fell from 41 seconds to under a second on this change alone. Less data moved, less memory held, fewer queries run.
They also document the contract. The interface lists precisely what the screen consumes; adding a column means editing the projection and the query together. Consumers cannot drift into lazy traversal because there is nothing lazy to traverse — the architecture makes the exception unrepresentable.
Default to projections for reads. Reserve full entities for writes and complex domain logic that genuinely needs managed state. Most screens display data; displaying data needs DTOs, not persistence context passengers. Share graph names across teams through the domain module to avoid duplicates.
Open-Session-in-View: the Painkiller That Spreads the Infection
Open-Session-in-View holds the Hibernate session open through view rendering so lazy touches succeed. The exception disappears — and every template loop, serializer walk, and nested include fires its own SELECT on a connection checked out for the whole render. What threw once now queries 400 times.
The costs compound under load. Connections held through rendering starve the pool: 3 concurrent reports held 48 connections and queued the entire API. Each page's query count scales with data volume, so growth converts linear pages into quadratic outages. Nothing errors; everything slows.
Disabling OSIV converts the slowness back into the exception — deliberately. Each LazyInitializationException then names one unloaded association, which earns one fetch plan: join, graph, or projection. The migration is mechanical: disable, run the suite, fix each failure with a declared plan, repeat until green.
Spring Boot's default has moved against OSIV, and new projects should keep it off from scaffolding. Audit inherited projects for the flag the way you would audit any performance-critical default: explicitly, with query counts before and after. Convenience that scales with your data is not convenience.
Boundaries and Proof: Transactions That Own the Read
Transactional boundaries decide where sessions live: one public service method owns the whole read — load, traverse, map — and only DTOs leave. Controllers receive data, templates render data, serializers emit data. No proxy survives the boundary because none crosses it.
Keep the annotation on public service methods. Private-method @Transactional is silently ignored by proxies, so the session never opens and lazy touches throw exactly as without it. Read-only hints on pure reads let the provider skip dirty checking — a free performance gain for reports.
Size boundaries to units of work, not to methods. One screen's read is one transaction even when it calls private helpers; splitting it across transactional methods detaches entities mid-thought. Helpers participate in the caller's transaction (the default propagation) rather than owning fragments.
Enforce with query-count tests: assert each endpoint runs its expected constant number of queries against fixture data. Counts catch N+1 that correctness tests miss and lazy gaps that OSIV would mask. A page whose queries scale with rows fails the test before it fails month-end. Keep OSIV disabled in every profile, including local dev, to prevent surprises.
40,000 Queries Hid Behind OSIV Until Month-End Hit 10,000 Rows
- Load-test read endpoints with production data volumes — N+1 is invisible at 10 rows and fatal at 10,000.
- Framework defaults are decisions: audit scaffolding flags like OSIV instead of inheriting them forever.
- Cap query counts per endpoint in CI; count assertions catch performance regressions that correctness tests cannot.
| File | Command / Code | Purpose |
|---|---|---|
| Order.java | @Entity | Proxies and Detachment |
| OrderRepo.java | public interface OrderRepo extends ListCrudRepository | JOIN FETCH |
| OrderGraphs.java | @NamedEntityGraph(name = "order.withLines", | EntityGraphs |
| OrderSummary.java | public interface OrderSummary { | DTO Projections |
| ReportService.java | @Service | Boundaries and Proof |
Key takeaways
Common mistakes to avoid
5 patternsReturning entities to the view and lazy-loading in templates
Enabling Open-Session-in-View to silence the exception
Marking everything FetchType.EAGER
Splitting one read across several @Transactional methods
Serializing entities directly to JSON
Interview Questions on This Topic
What throws LazyInitializationException?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Spring. Mark it forged?
5 min read · try the examples if you haven't