skip to content

Entity equals/hashCode & @NaturalId

Writing equals/hashCode that survive proxies, detachment, and id assignment — one of the most-asked Hibernate questions because the naive Lombok version corrupts Sets. Also covers natural business keys and Hibernate's dedicated @NaturalId loading API.

part ofHibernateoverview, primer and where to startread it →
on this pageshow

questions

5

Do JPA/Hibernate entity classes need custom equals() and hashCode(), or is the reference comparison inherited from java.lang.Object good enough? When does the choice actually start to matter?

level: juniorimportance: must knowfreq 62%

answer

  1. Persistence context = identity map, one object per row
  2. Breaks on detach / merge / two sessions / serialization
  3. hashCode must never change inside a Set
  4. Business key, assigned UUID, or constant hashCode
  5. Never mutable fields, never Lombok @Data on entities

basics

~20 s

Object.equals compares references. That is correct only while every instance comes from one open persistence context, which guarantees one object per row. As soon as entities are detached, re-loaded in another session, merged, or compared across sessions, two objects represent the same row and reference equality says false — so you override equals/hashCode using a stable identifier.

solid answer

~50 s

Inside a single open persistence context Hibernate guarantees **one object instance per database row**, so `==` and the inherited `equals` are already correct there — many applications ship with no override at all and are fine. The default breaks the moment the same row is represented by two different instances: an entity loaded in session A compared with the same row loaded in session B, a detached instance compared with the result of `merge()`, an entity serialized into an HTTP session or cache and compared later, or an object put into a `HashSet` in one unit of work and looked up in another. `hashCode` matters as much as `equals`, because `HashSet`/`HashMap` and therefore JPA `Set` associations bucket by hash first. So the rule is: override when entities leave the persistence context or live in hash-based collections; and when you do override, base it on something stable — a business/natural key or an application-assigned id — not on mutable state.

code

java · 8 lines
java
Order a = em1.find(Order.class, 42L);
Order b = em2.find(Order.class, 42L);

a == b;        // false — different objects
a.equals(b);   // false with Object.equals, though both are row 42

Order same = em1.find(Order.class, 42L);
a == same;     // true — identity map inside one context

go deeper

for a junior

Know that Object.equals compares references, that one persistence context returns one object per row, and that detached or re-loaded entities break that. Name one safe basis for equality.

for a middle

Explain the identity-map guarantee and its boundary, and pick between business key, assigned UUID, and generated-id-with-constant-hashCode with reasons. Mention the hashCode stability contract.

for a senior

Bring the failure modes you have hit in production: duplicates in a mapped Set, merge() returning a different instance, Lombok-generated equals recursing through a bidirectional association.

for a principal

Frame it as a modelling decision — assigned identifiers as a design stance that makes entities behave like values outside the ORM, and the cost of that choice on insert batching and index locality.

## What the default actually gives you `java.lang.Object.equals` is reference equality (`this == other`), and `Object.hashCode` is derived from the object's identity. For entities this is not obviously wrong, because a JPA persistence context (Hibernate's `Session`) acts as an **identity map**: for a given entity type and primary key it will return the *same* Java object for the whole lifetime of that context. Load `Order#42` twice in one session and you get one instance, so `a == b` is true and the default `equals` is correct. This is why plenty of code with no override at all works. The question is what happens outside that one-session window. ## Where reference equality breaks 1. **Two sessions.** Load `Order#42` in session A, load it again in session B: two distinct objects for one row. `a.equals(b)` is false under the default. 2. **Detach / merge.** You send an entity to a web layer, it comes back modified, and you call `merge()`. `merge` returns a *managed copy*, not your detached instance. Comparing the argument with the return value under the default gives false. 3. **Serialization.** Entities put in an HTTP session, a distributed cache, or sent over the wire and deserialized are new objects. 4. **Hash-based collections.** A `Set<OrderLine>` mapped as an association, or any `HashSet` you build yourself, uses `hashCode` to pick a bucket and `equals` to resolve collisions. If the same row appears twice as two instances, the set holds two elements — a silent duplicate that can turn into two INSERTs or a broken `contains()`. 5. **Second-level cache / DTO round-trips.** Anything that reconstructs entities from stored state produces fresh instances. ## What "stable identifier" means The hard requirement from the `equals`/`hashCode` contract is that **hashCode must not change while the object is inside a hash-based collection**, and that equality is reflexive, symmetric, transitive and consistent. Entities violate this easily because the most obvious identifier — a database-generated primary key — is `null` before `persist()` and non-null after. An `equals` built on a null id makes every transient instance equal to every other transient instance; a `hashCode` built on it changes at flush time and the object is lost in its bucket. The three workable options are: - **A business/natural key**: an immutable, non-null, unique attribute the domain already has (ISBN, order number, email, an SKU). Best fit when such a key genuinely exists and truly never changes. - **An application-assigned identifier**: generate a `UUID` in the constructor so the id is non-null from birth and never mutates. This is the most mechanical, always-available answer. - **Generated id plus a constant hashCode**: `hashCode()` returns a fixed value (e.g. `getClass().hashCode()` or `31`), and `equals` returns true only when both ids are non-null and equal. Hash stability is preserved by construction; the price is that all instances of that type land in one bucket, which is acceptable for the small sets typical of an aggregate but not for a set with thousands of elements. What is *not* workable is equality over mutable business fields (`name`, `status`, `updatedAt`): changing a field silently changes the object's identity while it sits in a set. ## Practical guidance If your entities never leave a single transaction and you never place them in a `Set`, leaving `equals`/`hashCode` alone is a legitimate, defensible choice — and it is one of the few situations where "do nothing" is the right answer. State that explicitly in an interview; it shows you know *why* the override exists rather than cargo-culting it. Also be aware of two related traps. First, IDE-generated `equals`/`hashCode` (or Lombok's `@Data` / `@EqualsAndHashCode` with no configuration) includes every field, including associations — which can trigger lazy loading or infinite recursion between two sides of a bidirectional link. Second, `equals` must tolerate being handed a Hibernate lazy **proxy** of the same entity, which is a generated subclass: `getClass() != other.getClass()` will then reject an object that represents the same row. ## The short version One session, one object — default equality is fine. Multiple sessions, detachment, serialization, or hash collections — override, and base the override on an identifier that is non-null and immutable from the moment the object exists.

  • If the persistence context already guarantees one instance per row, why not just always compare with ==?
    Because the guarantee is scoped to one open context. The moment an entity is detached, serialized, re-read in another session, or replaced by the managed copy that merge() returns, you hold two objects for one row and == is false. Reference equality is correct but only inside a boundary you cannot enforce for callers.
  • Is it acceptable to ship entities with no equals/hashCode override at all?
    Yes, if the entities never appear in hash-based collections and never leave the transaction that loaded them — the identity map makes the inherited implementation correct. It is a conscious choice, not neglect, and it is safer than a wrong override built on a generated id or mutable fields. Document the assumption so a later @OneToMany Set mapping does not quietly break it.
  • What is wrong with Lombok's @Data or @EqualsAndHashCode on an entity?
    They include every field by default, so equality depends on mutable state and on associations. Touching an association inside equals can trigger lazy loading outside a session or recurse infinitely between both sides of a bidirectional relationship. If you use Lombok on entities, restrict it explicitly to the identifier field.

Inside one session Hibernate is like a coat check that always hands back your exact coat, so 'is it the same object?' works. Across sessions you get an identical coat from a different rack — you need a name tag (stable id), not the coat's physical identity.

saying these in an interview costs you the question

  • "Just let the IDE generate equals/hashCode over all fields" — includes mutable state and associations
  • "Reference equality is always wrong for entities" — it is correct inside a single persistence context
  • Overriding equals but not hashCode (or vice versa), breaking Set and Map behavior
  • "equals on the id is always safe" — not when the id is database-generated and null before persist
  • Using @Data / @EqualsAndHashCode from Lombok on entities without excluding associations

context

open as a page

You override equals() and hashCode() on a JPA entity using its database-generated primary key, add a brand-new instance to a HashSet, and then persist it. Why can the set stop finding that object, and how do you write equals/hashCode so it cannot happen?

level: middleimportance: must knowfreq 66%

basics

~20 s

Before persist the generated id is null, so hashCode is computed from null; at flush the id is assigned and hashCode changes, but the object still sits in the bucket chosen by the old hash — contains() and remove() miss it. Fix: constant hashCode plus id-equality, or an identifier assigned before persist.

open as a page

What does Hibernate's @NaturalId annotation give you beyond declaring a plain unique column, and how does loading through Session.byNaturalId() / bySimpleNaturalId() differ from writing a query on that column?

level: middleimportance: should knowfreq 30%

basics

~20 s

@NaturalId marks the domain's real business key alongside the surrogate @Id. Hibernate then offers byNaturalId()/bySimpleNaturalId() lookups that resolve the natural key to the primary key and load through the normal identity map and caches, so a repeat lookup can avoid SQL entirely. It also enforces immutability by default.

open as a page

Explain why a Java equals() implementation that starts with getClass() != other.getClass(), or that reads the other object's fields directly, can misbehave when Hibernate hands you a lazily-loaded proxy — and what to write instead.

level: seniorimportance: should knowfreq 42%

basics

~20 s

A lazy reference is a generated subclass of your entity, so getClass() returns Order$HibernateProxy, not Order, and a getClass() check rejects the same row. Direct field access on a proxy reads the uninitialized subclass fields (null), bypassing the interception. Use instanceof and call getters.

open as a page

You are designing the identity model for a domain whose objects routinely live outside an open persistence context — cached DTO graphs, HTTP sessions, sets nested inside aggregates. How would you decide between assigning identifiers in the constructor (for example UUIDs), relying on a business/natural key, and using database-generated sequence identifiers as the basis for object equality?

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Pick by whether an identifier exists at construction time. Assigned UUIDs always do, so equality is simple everywhere, at the cost of index locality — mitigate with time-ordered UUIDs. A true immutable business key is best when one genuinely exists. Generated sequence ids are cheapest in the database but need the constant-hashCode workaround.

open as a page