Skip to main content

Hibernate 7 get() vs getReference(): Four Calls, Four Outcomes

session.get() and session.getReference() look interchangeable at a glance, but the difference isn’t “eager vs lazy” — it’s what you’re telling Hibernate you need. This piece runs four calls against Hibernate 7.4.1.Final, builds the full same-session matrix, and pins down exactly where the proxy identity contract breaks — including one same-session result that the usual L1-cache explanation gets wrong.

session.get() and session.getReference() look interchangeable at a glance — both take an entity class and an id, both hand back an object. They aren’t, and the difference isn’t just “eager vs lazy.” The right way to think about the two methods is what you’re telling Hibernate you need: get() says “I need the entity” — Hibernate will do whatever it takes, including a SELECT you didn’t expect, to hand you something with real data behind it. getReference() says “I need a reference” — Hibernate hands back the cheapest object that satisfies that and defers everything else.

That framing predicts more than the usual comparison table does — in particular, it predicts a same-session result that the L1-cache explanation gets wrong. This piece runs four calls against Hibernate 7.4.1.Final, builds the full same-session matrix, and pins down where the proxy identity contract actually breaks. Every number below came from a JUnit test, not from memory — the test class and its full transcript are in the companion repository linked in the callout just below.

Versions used in this article. Hibernate ORM 7.4.1.Final (GA 2026-06-09) on Spring Boot 4.1.0 (GA 2026-06-10). Spring Boot 4.1.0’s own dependency management resolves hibernate.version to 7.4.1.Final exactly, so no POM override is needed to get this pin — one Boot patch release later, 4.1.1, resolves to a different Hibernate patch (7.4.5.Final) from the same “4.1” line, documented in the repo below. The runnable project, the full JUnit test suite, and every captured transcript live in the hibernate-demo companion repository.

Hibernate 7 removed load() — here’s what replaced it

Classic Hibernate provided two retrieval methods: session.get() and session.load(). Hibernate 7 removed load() entirely — along with save(), update(), and saveOrUpdate(). Its exact replacement is the JPA-standard session.getReference(), which behaves identically: same deferral, same proxy, same exception-on-access contract. get() still exists and corresponds to JPA’s find(). The code below uses getReference() so it compiles on Hibernate 7; everything said about it applies unchanged to whatever you remember about load().

Contract vs. observation

The contract for these two methods is short: get() fetches now and may return null; getReference() defers and may throw once accessed. Both methods honor that contract, and every tutorial covers it. What none of them cover is what happens once the same id has already been touched once in the same session — and that turns out to be governed by the persistence context, not by which method you call second. That’s the part worth actually running instead of assuming.

Four calls, four outcomes

#CallFires a SELECT at the call site?Row missingRow exists
1session.get(Book.class, id)Yes, immediatelyreturns nullreturns the real entity
2session.get(), missing idYes, immediatelyreturns null
3session.getReference(Book.class, id)No — deferred to first non-id accessorproxy returned, no error yetproxy returned, no SELECT yet
4getReference(), missing id, then .getTitle()Yes, on first accessor callthrows jakarta.persistence.EntityNotFoundException

Row 4 is worth being precise about: the exception class is jakarta.persistence.EntityNotFoundException, not org.hibernate.ObjectNotFoundException — a name still used in a lot of older Hibernate discussion, including an earlier draft of this article. Running it against 7.4.1.Final is what settled which one this version actually throws.

The same-session matrix

Four combinations, both calls against the same id in the same session, statistics cleared right before the second call so prepareStatementCount reflects only that call:

First callSecond callprepareStatementCount for 2nd call2nd call returns
get()get()0same instance (L1 cache hit)
getReference()getReference()0same proxy instance
get()getReference()0the same, already-real instance — not a new proxy
getReference()get()1the same instance, now initialized

The last row doesn’t follow from “it’s already in the L1 cache, so nothing happens.” It does happen: calling get() against an id that already has an uninitialized proxy sitting in the persistence context still fires a SELECT. This is exactly the mental model from the top of this article paying off — get()’s contract is “hand back a real, usable entity,” and an uninitialized proxy doesn’t satisfy that, so Hibernate initializes it in place and returns the same object reference, now with real data behind it. The reverse direction needs nothing further, because a fully-loaded instance already satisfies whatever getReference() was going to ask for.

This wasn’t something I went looking for — it fell out of writing the fourth cell of the matrix and reading the log, which is the actual argument for building all four combinations instead of reasoning about two of them and assuming the rest.

The proxy identity experiment

A Hibernate proxy is a runtime-generated subclass of your entity, holding the id you provided with every other field left uninitialized until the first non-id accessor fires the deferred SELECT. Six checks against the same proxy, all in one test:

assertThat(proxy).isInstanceOf(Book.class);                        // true
assertThat(Hibernate.getClass(proxy)).isEqualTo(Book.class);        // true -- the REAL class
assertThat(proxy.getClass()).isNotEqualTo(Book.class);              // true -- Book$HibernateProxy
assertThat(real.equals(proxy)).isFalse();                           // false
assertThat(proxy.equals(real)).isFalse();                           // false, both directions
assertThat(new HashSet<>(List.of(real)).contains(proxy)).isFalse(); // can't see they're the same row

instanceof and Hibernate.getClass() both see through the proxy to the real type. getClass() does not — a proxy’s runtime class is a generated Book$HibernateProxy, never Book itself, which is exactly why Hibernate.getClass() exists as the “give me the real entity class” escape hatch. equals() breaks in both directions because Book never overrides it, so Java’s default falls back to reference identity — this is not a Hibernate quirk, it’s plain Java doing exactly what an un-overridden equals() always does once two different objects represent the same row. The HashSet check is the concrete cost of that: a collection built on default equals()/hashCode() cannot recognize the proxy and the real instance as the same database row, silently.

A seventh check, run separately, confirms the other well-known proxy trap: a proxy that outlives the session that created it throws org.hibernate.LazyInitializationException the moment a non-id accessor is called — a different failure from EntityNotFoundException, worth not confusing with it.

Book proxy = session.getReference(Book.class, id);
tx.commit();
session.close();

proxy.getTitle(); // throws org.hibernate.LazyInitializationException:
                   // Could not initialize proxy [Book#1] - no session

Why load() wins for relationship management

The practical payoff of “I only need a reference” shows up when wiring a foreign key. Suppose you want to add a Comment to a Post with id 500.

// Using get() -- wastes a SELECT you never needed
Post post = session.get(Post.class, 500L);          // fires SELECT ... WHERE id=500
Comment comment = new Comment("Great post!", post);
session.persist(comment);                            // fires INSERT

// Using getReference() -- zero extra queries
Post postProxy = session.getReference(Post.class, 500L); // no SQL at all
Comment comment2 = new Comment("Great post!", postProxy);
session.persist(comment2);                            // fires INSERT -- only 1 query total

In a system processing thousands of inserts per second, one saved SELECT per operation compounds fast — but only when the object is genuinely just a foreign-key placeholder. Reach for get() the moment you need to read or verify anything else about it.

Decision table

You haveYou needCall
An id, unsure if the row existsThe actual data, or a safe existence checkget()
An id, certain the row existsOnly a reference to set a foreign keygetReference()
An id already fetched once this sessionAnythingReused — except getReference()get(), which still fires a SELECT to initialize the existing proxy
A proxy that might outlive this sessionSafe access laterInitialize it now (Hibernate.initialize(proxy)), or don’t let it leave the session
Mixed get()/getReference() references going into a Set or an equals() comparisonCorrect identity behaviorOverride equals()/hashCode() on the id — the default will not survive the proxy boundary

Frequently Asked Questions

Is session.load() deprecated in Hibernate 7?

It is not deprecated — it is gone. Hibernate 7.0 removed session.load() entirely, along with save(), update(), and saveOrUpdate(). Code that still calls load() will not compile against Hibernate 7; getReference() is the drop-in replacement.

Can I convert a proxy to a real object?

Yes. Hibernate.initialize(proxyObject) forces the database hit while the session is still open. Hibernate.unproxy(proxyObject) unwraps an already-initialized proxy into the real class, which is the fix for proxy.getClass().equals(Book.class) being false when you specifically need the true runtime class rather than Hibernate.getClass()’s answer.

What surprised me building this

Two things, not one. The proxy-equals-breaking result was expected going in, just not in its full shape — the guess was that equals() might be asymmetric, breaking one direction but not the other. It doesn’t; it fails identically both ways, which is simpler and slightly worse than the half-remembered version of this story usually gets described.

The one I didn’t expect at all was the getReference()get() row of the same-session matrix. The intuitive prediction — “the id is already in the L1 cache, so the second call is free” — is true for three of the four combinations and wrong for exactly this one, because get()’s contract requires more than presence in the cache; it requires the object behind that cache entry to actually be usable as loaded data. Getting three cells right and the fourth wrong in a way that only shows up by building all four is the entire argument for running the matrix instead of describing two of its cells from memory.

Full transcript, test class, and source: docs/01-get-vs-load.md in the companion repo. Reproduce it yourself:

$ git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git
$ cd hibernate-demo
$ ./mvnw -Dtest=GetVsGetReferenceTest test

Further Reading & Cross-References

No Comments yet!

Leave a Reply

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