# 01 — get() vs getReference() [← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md) Backs [ankurm.com: Hibernate 7 — get() vs load()](https://ankurm.com/hibernate-7-get-vs-load-which-one-should-you-actually-use/). Test class: [`GetVsGetReferenceTest`](../src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java). Run it yourself: ```bash git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git cd hibernate-demo ./mvnw -Dtest=GetVsGetReferenceTest test ``` Every number and exception class name below came from that command, not from documentation or memory. Raw captured output: [`docs/output/get-vs-getreference-tests.txt`](output/get-vs-getreference-tests.txt). ## Contract vs observation The JPA/Hibernate contract for these two methods is short: `get()` fetches now and may return `null`; `getReference()` defers and may throw once accessed. That contract is real and both methods honor it. What it doesn't tell you is what happens once the *same id* has already been touched once in the *same session* — and that's where the interesting behavior lives, because it's governed by the persistence context, not by the method you happen to call second. ## Mental model Stop thinking of `get()` vs `getReference()` as "eager vs lazy." Think of it as what you're telling Hibernate you need: - `get()` says **"I need the entity."** Hibernate will do whatever it takes — including firing a `SELECT` against an id it already has a reference for — to hand you something with real data behind it. - `getReference()` says **"I need a reference."** Hibernate will hand you the cheapest possible object that satisfies that and defers everything else, including telling you the row doesn't exist. That framing predicts the session-matrix results in the next section better than "eager vs lazy" does — see the `getReference()` → `get()` row in particular. ## 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(Book.class, id)` (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 | `session.getReference(Book.class, id)` (missing id), then `.getTitle()` | Yes, on first accessor call | throws `jakarta.persistence.EntityNotFoundException` on access | — | Row 4 is worth being precise about: the exception class is `jakarta.persistence.EntityNotFoundException`, not `org.hibernate.ObjectNotFoundException` — the name still used in a lot of older Hibernate discussion. Running it against 7.4.1.Final settles which one this version actually throws. ## Same-session matrix Four combinations, both calls against the *same id* in the *same session*, each with 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** | the same instance (L1 cache hit) | | `getReference()` | `getReference()` | **0** | the 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 is the one that 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`. `get()`'s contract is "hand back a real, usable entity" — 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 (`get()` then `getReference()`) needs nothing further, because a real, 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 row of the matrix and reading the log, which is the actual argument for building the matrix instead of reasoning about two of the four cells and assuming the rest. ## Proxy identity experiment Six checks against the same proxy, all in one test: ```java assertThat(proxy).isInstanceOf(Book.class); // true assertThat(Hibernate.getClass(proxy)).isEqualTo(Book.class); // true -- the REAL class assertThat(proxy.getClass()).isNotEqualTo(Book.class); // true -- proxy.getClass() is 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();// a HashSet 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 Hibernate proxy's runtime class is a generated `Book$HibernateProxy`, never `Book` itself, which is 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 (a proxy and a loaded instance) 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, in a separate test, 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 on it — a different failure from `EntityNotFoundException`, worth not confusing with it. ## What surprised me building this Two things, not one. The proxy-equals-breaking result was expected going in, just not in its full shape — I expected `equals()` to be asymmetric or to depend on which side calls it. It doesn't; it fails identically in both directions, which is simpler and worse than a half-remembered version of this story usually gets described. The one I didn't expect at all was the `getReference()` → `get()` row of the 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 matrix 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. Predicting three cells right and getting the fourth wrong in a way that only shows up by actually building all four is the whole argument for running the matrix instead of describing two of its cells from memory. ## 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 | Whatever's already loaded is reused — see the matrix above for exactly when a `SELECT` still fires anyway | | A proxy that might outlive this session | Safe access later | Initialize it now (`Hibernate.initialize(proxy)`), or don't let it leave the session | | Two references to the same row from mixed `get()`/`getReference()` calls, going into a `Set` or `equals()`-based comparison | Correct identity behavior | Override `equals()`/`hashCode()` on the id — the un-overridden default will not survive the proxy boundary | [← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md)