Skip to main content

Hibernate 7 Proxies and LazyInitializationException: Causes and Cures

What a Hibernate 7 proxy actually is, the two different LazyInitializationException message templates (not one), and why naive equals() breaks against a ByteBuddy proxy. Proves loadgraph vs fetchgraph with real SQL, reproduces the Open Session in View warning masking a real failure with a live HTTP round trip, and covers hibernate.enable_lazy_load_no_trans now that it carries an @Unsafe marker.

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 a SELECT and what it returns — is covered in Hibernate 7: get() vs load(). This article picks up from there: you already have a proxy (from getReference(), or from a lazy association), and now you need to know what breaks and how to fix it.

Which part is for you

PartRead this if…
1 — BeginnerYou just hit LazyInitializationException for the first time and need the fastest fix
2 — IntermediateYou need the exact exception text to grep for, why proxies break equals()/hashCode(), or real SQL for loadgraph vs fetchgraph
3 — AdvancedYou’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 ProxyBook is true — the generated class extends your entity.
  • proxy.getClass() == ProxyBook.class is false. Code that branches on getClass(), or relies on the JPA-default equals()/hashCode() (object identity), breaks silently: a HashSet<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.

What happens when you call proxy.getTitle() Field access intercepted by LazyInitializer initialized == false? yes session still open? yes session.immediateLoad() -> flag=true no throws LazyInitializationException

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:

HintSQL joinspublisher after fetchreviews after fetch
none (plain find())left join proxy_publisher onlyinitialized (mapped EAGER)not initialized (mapped LAZY)
loadgraphleft join proxy_publisher and left join proxy_reviewinitializedinitialized
fetchgraphleft join proxy_review onlynot 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 caseStrategyWhy
Search results / list viewsDTO projectionNo managed entities, no proxies, minimum data over the wire
Main view, predictable object treeEntity GraphOne round trip, and you keep managed entities for later writes
Conditional business logicHibernate.initialize()Only load children when an if-branch actually needs them
Bulk export / high-throughput readStatelessSessionBypasses 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 List collections at once — verbatim: cannot simultaneously fetch multiple bags. Fix is Set instead of List, 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 an OutOfMemoryError. 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 deliberate JOIN FETCH — and set open-in-view=false explicitly so the framework stops warning you about a default you’re no longer relying on.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.