Call book.getPublisher() and Hibernate hands you a placeholder — a proxy holding only the id — instead of hitting the database immediately. That’s the whole point of lazy loading. It also means the placeholder is only good for as long as the Session that created it stays open. Let it leak past that boundary — into a serializer, a view, a second thread — and accessing it throws LazyInitializationException.
This article is about the exception and its cures: what the proxy actually is, the two different messages it throws depending on what you touched, why naive equals() breaks against it, and what Entity Graphs, Hibernate.initialize(), and Open Session in View actually do about it — proven with real SQL and a live HTTP round trip, not restated as folklore.
This isn’t the get() vs getReference() article. That choice — when Hibernate fires aSELECTand what it returns — is covered in Hibernate 7: get() vs load(). This article picks up from there: you already have a proxy (fromgetReference(), or from a lazy association), and now you need to know what breaks and how to fix it.
Which part is for you
| Part | Read this if… |
|---|---|
| 1 — Beginner | You just hit LazyInitializationException for the first time and need the fastest fix |
| 2 — Intermediate | You need the exact exception text to grep for, why proxies break equals()/hashCode(), or real SQL for loadgraph vs fetchgraph |
| 3 — Advanced | You’re deciding on enable_lazy_load_no_trans, debugging an OSIV-masked 500, or wondering what bytecode enhancement changes |
Verified against. Hibernate ORM 7.4.5.Final, Spring Boot 4.1.1 (GA 2026-07), Spring Framework 7.0.9, Jakarta Persistence 3.2.0, JDK 25 (Temurin), H2 2.4.240. Every exception message and SQL statement below is quoted verbatim from a real run.
Part 1 — The fix that gets you unstuck today
The failure loop is always the same shape: a service method loads an entity and returns it, the transaction ends and the Session closes, and later — a Jackson serializer, a template engine, a second thread — something calls a getter on an association that was never touched while the session was open. Hibernate can’t reach the database any more, and it throws.
The fastest fix: touch the association while the session is still open, before you return.
@Transactional(readOnly = true)
public ProxyBook getBookWithReviews(Long bookId) {
ProxyBook book = entityManager.find(ProxyBook.class, bookId);
if (!Hibernate.isInitialized(book.getReviews())) {
Hibernate.initialize(book.getReviews());
}
return book;
}
Hibernate.isInitialized(Object) checks proxy state without firing SQL; Hibernate.initialize(Object) forces the load if it’s still a proxy. The cost is visible in the query log — a second, separate SELECT:
-- Query 1: the parent entity
select pb1_0.id, pb1_0.title, ... from proxy_book pb1_0 where pb1_0.id = ?
-- Query 2: triggered by Hibernate.initialize()
select r1_0.book_id, r1_0.id, r1_0.comment from proxy_review r1_0 where r1_0.book_id = ?
This works, and it’s the right tool for a genuinely conditional case — “only load the children if this if-branch needs them.” It is not the best default. Part 2 shows a way to get the same data in one round trip instead of two, and Part 3 shows the two ways people reach for that make things worse instead of better.
Part 2 — What’s actually going on, and how to debug it
The exception is not one message — it’s two
Every existing article on this topic, including the one this replaces, quotes a single wording. Run it for real and there are two different templates, depending on whether the frozen thing is a collection or a to-one proxy:
-- Collection (a LAZY @OneToMany), accessed after session close:
Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews' with key '1' (no session)
-- To-one proxy (a getReference() result), accessed after session close:
Could not initialize proxy [com.ankurm.hibernatedemo.proxy.ProxyBook#3] - no session
Note the capitalization on the second one: Could with a capital C, no session with a lower-case s — not the commonly-quoted “could not initialize proxy … no Session”. Both are org.hibernate.LazyInitializationException (confirmed via ex.getClass().getName()), but if you’re grepping logs for one exact phrase, you need both patterns.
What a proxy actually is
getReference() doesn’t hand you an instance of your entity class. In 7.4.5.Final it hands you a ByteBuddy-generated nested class:
getReference() runtime class: com.ankurm.hibernatedemo.proxy.ProxyPublisher$HibernateProxy
That’s not the Javassist-era naming (EntityName_$$_javassist_N) that still circulates in older posts — Hibernate moved its default bytecode provider to ByteBuddy years ago, and the proxy is a literal static nested class of the entity, not a same-package sibling. Two things follow, and both matter for code that isn’t expecting them:
proxy instanceof ProxyBookistrue— the generated class extends your entity.proxy.getClass() == ProxyBook.classisfalse. Code that branches ongetClass(), or relies on the JPA-defaultequals()/hashCode()(object identity), breaks silently: aHashSet<ProxyPublisher>containing the real, managed instance does not recognize a proxy for the exact same row as a member.
Hibernate.getClass(proxy) is the fix — it unwraps to the real entity class regardless of proxy state. This is the same identity trap the get()/getReference() article’s proxy identity experiment demonstrates from the other side; read together, the two cover the whole lifecycle from creation to this exception.
unproxy() has two overloads
Confirmed via javap against the 7.4.5.Final jar:
public static java.lang.Object unproxy(java.lang.Object);
public static <T> T unproxy(T, java.lang.Class<T>);
A no-cast version returning Object, and a typed version taking the target class. Useful when you need to hand a real entity, not a proxy subclass, to a library that checks the exact class. One side effect worth knowing: calling unproxy() on an uninitialized proxy initializes it — isInitialized() flips from false to true as a consequence, not just a precondition. The session must still be open when you call it, unless the proxy is already fully initialized.
Entity Graphs, demonstrated with real SQL — not just the rule restated
Every article states the rule: loadgraph keeps mapping defaults for anything not named in the graph; fetchgraph forces everything not named to LAZY, even if the mapping says EAGER. Almost none show it happening. Take one entity with two associations of different default fetch types — publisher (@ManyToOne, default EAGER, unmapped) and reviews (@OneToMany, explicit LAZY) — and one named graph mentioning only reviews:
| Hint | SQL joins | publisher after fetch | reviews after fetch |
|---|---|---|---|
none (plain find()) | left join proxy_publisher only | initialized (mapped EAGER) | not initialized (mapped LAZY) |
loadgraph | left join proxy_publisher and left join proxy_review | initialized | initialized |
fetchgraph | left join proxy_review only | not initialized (forced to LAZY) | initialized |
The middle column is the whole point: with fetchgraph, book.getPublisher() comes back as a ProxyPublisher$HibernateProxy even though the mapping says EAGER, because fetchgraph treats the graph as the entire fetch plan rather than an addition to the mapping’s defaults. Verbatim SQL for the fetchgraph and loadgraph cases:
-- fetchgraph: only the named attribute (reviews) is joined
select pb1_0.id, pb1_0.publisher_id, pb1_0.title, r1_0.book_id, r1_0.id, r1_0.comment
from proxy_book pb1_0 left join proxy_review r1_0 on pb1_0.id = r1_0.book_id
where pb1_0.id = ?
-- loadgraph: the named attribute AND the mapped-EAGER publisher both stay joined
select pb1_0.id, p1_0.id, p1_0.name, pb1_0.title, r1_0.book_id, r1_0.id, r1_0.comment
from proxy_book pb1_0
left join proxy_publisher p1_0 on p1_0.id = pb1_0.publisher_id
left join proxy_review r1_0 on pb1_0.id = r1_0.book_id
where pb1_0.id = ?
EntityGraph<ProxyBook> graph = entityManager.createEntityGraph(ProxyBook.class);
graph.addAttributeNodes("reviews");
// swap the hint key to see the difference above:
ProxyBook book = entityManager.find(ProxyBook.class, bookId,
Map.of("jakarta.persistence.fetchgraph", graph));
Decision matrix
| Use case | Strategy | Why |
|---|---|---|
| Search results / list views | DTO projection | No managed entities, no proxies, minimum data over the wire |
| Main view, predictable object tree | Entity Graph | One round trip, and you keep managed entities for later writes |
| Conditional business logic | Hibernate.initialize() | Only load children when an if-branch actually needs them |
| Bulk export / high-throughput read | StatelessSession | Bypasses the first-level cache and proxy system entirely |
Part 3 — The defaults that quietly hurt you
enable_lazy_load_no_trans: still there, still works, now flagged @Unsafe
This setting has a reputation for being on its way out. It is not gone in 7.4.5.Final — org.hibernate.cfg.TransactionSettings.ENABLE_LAZY_LOAD_NO_TRANS still resolves to hibernate.enable_lazy_load_no_trans, confirmed via javap. What’s new-ish: the constant now carries an @org.hibernate.cfg.Unsafe marker annotation — an empty marker interface Hibernate’s own source uses to flag settings it doesn’t want you reaching for, without actually removing them. Turn it on and a to-one proxy accessed after its session and transaction have both closed initializes successfully instead of throwing — Hibernate opens a temporary session behind the scenes to service exactly that one access. It still works. The @Unsafe label is Hibernate’s own commentary on why you generally shouldn’t reach for it, not a sign it’s deprecated.
Open Session in View: the default, its warning, and what it actually masks
spring.jpa.open-in-view defaults to true when left unset, and Boot prints this at startup — verified verbatim against org.springframework.boot.jpa.autoconfigure.JpaBaseConfiguration$JpaWebConfiguration‘s bytecode (note the package: spring-boot-jpa‘s own autoconfigure module in Boot 4, not the old combined one):
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed
during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
Proving what it actually masks needed a real HTTP request, not a description — OSIV is implemented by OpenEntityManagerInViewInterceptor, which only exists for a real servlet request. Same controller, same data, only the property differs:
-- open-in-view left unset (Boot default true): GET /osiv/books/1
200 {"title":"OSIV Default Book","publisher":{...},"id":1,"reviews":[{"comment":"Rendered fine","id":1}]}
-- open-in-view=false: GET /osiv/books/2 (identical entity graph, identical controller)
500 {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/osiv/books/2"}
The controller returns the entity directly — deliberately not a DTO, because the DTO is the fix and the point here is what happens without it. Server-side log for the 500, captured via CapturedOutput, not inferred:
Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write
JSON: Cannot lazily initialize collection of role 'com.ankurm.hibernatedemo.proxy.ProxyBook.reviews'
with key '2' (no session)]
Worth calling out on its own: the HTTP response body is a bare {"timestamp":…,"status":500,…}, even with server.error.include-message=always set — the failure happens inside Jackson’s HttpMessageConverter while writing the response, which Boot’s default error controller reports as a generic 500 rather than routing the original exception’s message into the error attributes. If you only look at the HTTP response, you learn nothing about why it failed; you have to check the application log, which is exactly what real incident response looks like for this failure mode. This is also the concrete argument against open-in-view=true as a “fix”: it doesn’t remove the exception, it just moves where it surfaces — from a controlled service-layer call into a serialization step deep inside the framework, with a connection held open for the whole request either way.
Bytecode enhancement: what’s real, and what wasn’t run here
Three enhancement settings are real and confirmed present in 7.4.5.Final via javap: org.hibernate.cfg.BytecodeSettings.ENHANCER_ENABLE_LAZY_INITIALIZATION, ENHANCER_ENABLE_DIRTY_TRACKING, and ENHANCER_ENABLE_ASSOCIATION_MANAGEMENT all exist. What this article does not do is wire up the actual Maven plugin and diff enhanced-vs-proxy behavior at runtime: both org.hibernate.orm.tooling:hibernate-enhance-maven-plugin:7.4.5.Final and org.hibernate.orm:hibernate-enhance-maven-plugin:7.4.5.Final came back “could not be resolved” against Maven Central from this environment. Any specific claim about what enhancement changes about proxy identity or getClass() behavior beyond what the settings above imply is unverified here — flagged rather than guessed at.
Long tail
- MultipleBagFetchException when join-fetching two
Listcollections at once — verbatim:cannot simultaneously fetch multiple bags. Fix isSetinstead ofList, reproduced with the actual exception in chapter 12’s output. - Large collections: initializing a collection is a
SELECT *with no implicit limit — a parent with tens of thousands of children risks anOutOfMemoryError. Paginate instead of initializing the whole collection. - @NotFound(action = NotFoundAction.IGNORE): forces Hibernate to fetch the association eagerly just to check for existence, which quietly defeats a LAZY mapping on that association.
Should open-in-view=true ever be your answer? No. It doesn’t fix LazyInitializationException, it hides where it happens and holds a database connection open for the full request in exchange. Fix the service layer instead — DTO, Entity Graph, or a deliberateJOIN FETCH— and setopen-in-view=falseexplicitly so the framework stops warning you about a default you’re no longer relying on.
Further reading
- Repo chapter: 11 — Proxies and lazy initialization
- Hibernate 7: get() vs load() — the same mechanism from the other direction: what get()/getReference() do before the proxy ever leaves the session
- Mastering Lazy Loading in Hibernate 7 — fetch strategies, OSIV, EntityGraphs, and the N+1 problem
- Hibernate 7 Entity Lifecycle — how the detached state leads to LazyInitializationException
- Hibernate 7 One-to-One Mapping — optional @OneToOne lazy loading and bytecode enhancement
- Hibernate 7 First Level Cache — how the persistence context stores proxy references
- Official Hibernate 7 User Guide — Bytecode Enhancement
No Comments yet!