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 resolveshibernate.versionto7.4.1.Finalexactly, 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
| # | Call | Fires a SELECT at the call site? | Row missing | Row exists |
| 1 | session.get(Book.class, id) | Yes, immediately | returns null | returns the real entity |
| 2 | session.get(), missing id | Yes, immediately | returns null | — |
| 3 | session.getReference(Book.class, id) | No — deferred to first non-id accessor | proxy returned, no error yet | proxy returned, no SELECT yet |
| 4 | getReference(), missing id, then .getTitle() | Yes, on first accessor call | throws 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 call | Second call | prepareStatementCount for 2nd call | 2nd call returns |
get() | get() | 0 | same instance (L1 cache hit) |
getReference() | getReference() | 0 | same proxy instance |
get() | getReference() | 0 | the same, already-real instance — not a new proxy |
getReference() | get() | 1 | the 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 have | You need | Call |
| An id, unsure if the row exists | The actual data, or a safe existence check | get() |
| An id, certain the row exists | Only a reference to set a foreign key | getReference() |
| An id already fetched once this session | Anything | Reused — except getReference() → get(), which still fires a SELECT to initialize the existing proxy |
| A proxy that might outlive this session | Safe access later | Initialize it now (Hibernate.initialize(proxy)), or don’t let it leave the session |
Mixed get()/getReference() references going into a Set or an equals() comparison | Correct identity behavior | Override 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
- 📘 EntityManager.find() vs. getReference() — the JPA-standard equivalents of get() and load()
- 📘 Hibernate 7 Proxies and LazyInitializationException — how to handle proxy pitfalls in production
- 📘 Master the Hibernate 7 Entity Lifecycle — persistent, transient, detached, and removed states
- 🔗 Official Hibernate 7 User Guide — Obtaining references
- 📘 hibernate-demo: get() vs getReference() companion repo — GetVsGetReferenceTest.java, the full transcript, and every table above
No Comments yet!