Hibernate equals and hashCode for Entities Across Sessions

Hibernate returns one object per row inside a session but a new object in every other session. Implement equals() and hashCode() for entities with a generated id, a business key or a UUID, make them proxy-safe, and avoid Lombok @Data.

logo

Two Hibernate entities are equal when they represent the same database row, but Java only knows that if we tell it through equals() and hashCode(). Inside one session, Hibernate returns the same Java object for a row, so == already works. Objects loaded in different sessions are different objects, and the default equals() inherited from Object calls them different too.

We need entity equality when entities from different sessions meet, for example in a HashSet of members collected over several requests, or when we compare a detached member with a freshly loaded one.

The following example is a library member identified by an email. The safest equals() and hashCode() compare the email, because it exists before the row is saved and stays the same when Hibernate assigns the id.

@NaturalId
@Column(nullable = false)
private String email;

@Override
public boolean equals(Object o) {
  if (this == o) return true;
  if (!(o instanceof Member other)) return false;
  return getEmail().equals(other.getEmail());
}

@Override
public int hashCode() {
  return getEmail().hashCode();
}
Member a = em.find(Member.class, id);     // select ... from Member m1_0 where m1_0.id=?
Member b = em.find(Member.class, id);     // no SQL, a == b is true
Member c = emf.callInTransaction(other -> other.find(Member.class, id));   // a second session

boolean same  = (a == c);                 // same = false, two objects
boolean equal = a.equals(c);              // equal = true, same email

Notice that a and c are two objects, but equals() still returns true because both hold the same email.

Next, we look at why one session returns the same object and two sessions do not, and then compare three ways to implement equals() and hashCode().

1. Identity and Equality of Hibernate Entities

Java has two ways to compare objects, and they answer different questions.

CheckQuestion it answersDefault from Object
a == bDo both variables point to the same object in memory?Always this
a.equals(b)Do both objects mean the same thing?Same as ==
a.hashCode()Which slot of a HashSet or HashMap holds the object?Based on the object, not its data

The Object contract requires that objects that are equals() return the same hashCode(). A HashSet first picks a slot by hashCode() and then compares with equals() inside that slot, so when the hash code is wrong, the set looks in the wrong slot.

1.1. One Session Returns the Same Instance

A session (an EntityManager in Jakarta Persistence) keeps a persistence context, which is a map from entity id to the object it loaded. Jakarta Persistence requires one unique entity instance per row in a persistence context, so a second em.find() for the same id returns the object from that map, without SQL.

emf.runInTransaction(em -> {
  Member a = em.find(Member.class, id);   // select m1_0.id,m1_0.email,m1_0.name from Member m1_0 where m1_0.id=?
  Member b = em.find(Member.class, id);   // no SQL
  boolean same = (a == b);                // same = true
});

Inside one session, == is a correct equality check for entities, even without equals().

1.2. Different Sessions Return Different Instances

Each session has its own persistence context, so a second session runs its own SELECT and builds a new object from the same row.

Member a = emf.callInTransaction(em -> em.find(Member.class, id));   // select ... where m1_0.id=?
Member b = emf.callInTransaction(em -> em.find(Member.class, id));   // select ... where m1_0.id=?

boolean same  = (a == b);         // same = false
boolean equal = a.equals(b);      // equal = false, Object.equals() is ==

After the transaction ends, a and b are detached entities, so Hibernate no longer tracks them, and nothing connects the two objects.

Session 1 calls em.find for Member 1 twice and both variables a and b point to one Member object A, so a == b is true; Session 2 calls em.find once and c points to a second object B; both objects come from the same Member table row id 1, lokesh@mail.com, Lokesh, and a == c and the default a.equals(c) are false
One row can exist as several Java objects, one per session. Only our own equals() can say they are the same member.

1.3. What Breaks in a HashSet With the Default equals()

The problem shows up when objects from different sessions meet in one collection, for example members collected over several requests, or a member from a cache compared with a freshly loaded one. With the default equals(), the set keeps both copies of the row and cannot find a third one.

Set<Member> members = new HashSet<>();
members.add(a);
members.add(b);                        // b is the same row as a, from another session

int size = members.size();             // 2
boolean found = members.contains(c);   // false, c is the same row from a third session
boolean removed = members.remove(c);   // false

When entities from different sessions are compared or kept in a Set or as Map keys, the entity class needs its own equals() and hashCode().

2. Implementing equals() and hashCode() for an Entity

The following example is a library that identifies its members by their membership email, and it runs on Hibernate 7.4, Java 25 and an in-memory H2 database. The complete project on GitHub maps one Member class per option, prints the SQL and includes 27 JUnit tests.

2.1. The Member Entity and the Three Rules

The Member entity has a generated @Id and two plain columns, email and name.

@Entity
public class Member {

  @Id
  @GeneratedValue
  private Long id;          // null until persist

  private String email;     // "lokesh@mail.com"
  private String name;      // "Lokesh"
}

A correct equals() and hashCode() for an entity follows three rules.

  • Two copies of the same row are equal, whichever session loaded them.
  • Two different new members are not equal, even before they have an id.
  • The hash code does not change while the object sits in a HashSet, including the moment em.persist() assigns the id.

There are three common ways to meet the three rules.

  • Compare the generated id.
  • Compare a business key such as the email.
  • Assign a UUID id in Java.

2.2. Comparing the Generated Id

The first version most of us write compares the ids with Objects.equals() and hashes them with Objects.hash(). This version breaks two rules, because a new entity has a null id until persist().

@Override
public boolean equals(Object o) {
  if (this == o) return true;
  if (!(o instanceof Member other)) return false;
  return Objects.equals(getId(), other.getId());
}

@Override
public int hashCode() {
  return Objects.hash(getId());
}
Member lokesh = new Member("lokesh@mail.com", "Lokesh");
Member alex   = new Member("alex@mail.com", "Alex");
boolean equal = lokesh.equals(alex);              // true, both ids are null

Set<Member> members = new HashSet<>();
members.add(lokesh);                              // lokesh.hashCode() = 31
emf.runInTransaction(em -> em.persist(lokesh));   // select next value for Member_SEQ
                                                  // insert into Member (email,name,id) values (?,?,?)
int hash = lokesh.hashCode();                     // 32, the id is now 1
boolean found = members.contains(lokesh);         // false
boolean removed = members.remove(lokesh);         // false, the set cannot remove it either

The set stored lokesh in the slot for hash 31. After persist(), the set looks in the slot for 32.

Two rows of three steps. With hashCode based on Objects.hash(id): add with id null gives hash 31 and stores Lokesh in the slot for 31, persist sets id 1 and hash 32, and contains looks in the empty slot for 32 and returns false. With a stable hashCode: the hash h stays the same through add, persist and contains, which finds Lokesh and returns true
The set stores the object under the hash code it had when we added it. A hash code that changes on persist() hides the object from its own set.

The fix keeps the id comparison but treats a null id as “not equal”, and returns a hash code that never changes.

@Override
public boolean equals(Object o) {
  if (this == o) return true;
  if (!(o instanceof Member other)) return false;
  return getId() != null && getId().equals(other.getId());
}

@Override
public int hashCode() {
  return Member.class.hashCode();   // the same for every Member
}
boolean equal = lokesh.equals(alex);                           // false, ids are null
members.add(lokesh);
emf.runInTransaction(em -> em.persist(lokesh));
boolean found = members.contains(lokesh);                      // true
boolean sameRow = copyFromSession1.equals(copyFromSession2);   // true, same id

A constant hash code is correct but puts every member in the same slot, so lookups in a large HashSet compare the object with every element. The extra comparisons do not matter for the small collections of a typical entity, but a constant hash code is the wrong choice for a HashMap with thousands of members as keys.

2.3. Comparing a Business Key Such as the Email

A business key is a field that identifies the member in the real world and is known before the row is saved, and in our library that is the membership email. Hibernate’s @NaturalId marks it as the natural key and adds a unique constraint.

@NaturalId
@Column(nullable = false)
private String email;

public Member(String email, String name) {
  this.email = Objects.requireNonNull(email);
  this.name = name;
}

@Override
public boolean equals(Object o) {
  if (this == o) return true;
  if (!(o instanceof Member other)) return false;
  return getEmail().equals(other.getEmail());
}

@Override
public int hashCode() {
  return getEmail().hashCode();
}
create table Member (id bigint not null, email varchar(255) not null, name varchar(255), primary key (id), unique (email))

The email exists from the constructor on, so the hash code is the same before and after persist(). A new object built from a form is equal to the stored member before it has an id.

members.add(lokesh);
emf.runInTransaction(em -> em.persist(lokesh));
boolean found = members.contains(lokesh);   // true

Member fromForm = new Member("lokesh@mail.com", "Lokesh G");
Long formId = fromForm.getId();             // null
boolean equal = fromForm.equals(loaded);    // true, same email

The key must be unique and never null. It must also stay the same for the life of the member. If members can change their email, the email is not a good key, because a member whose email changes while it sits in a HashSet is lost in the same way as in section 2.2.

2.4. Assigning a UUID When the Object Is Created

When there is no business key, we can create the id in Java instead of in the database. A UUID field initializer gives every new Member its id at construction.

@Id
private UUID id = UUID.randomUUID();

@Override
public boolean equals(Object o) {
  if (this == o) return true;
  if (!(o instanceof Member other)) return false;
  return getId().equals(other.getId());
}

@Override
public int hashCode() {
  return getId().hashCode();
}
Member lokesh = new Member("lokesh@mail.com", "Lokesh");
UUID id = lokesh.getId();           // not null
members.add(lokesh);
emf.runInTransaction(em -> em.persist(lokesh));   // insert into Member (email,name,id) values (?,?,?)
boolean found = members.contains(lokesh);         // true

The cost shows up with em.merge(). Hibernate cannot tell from a non-null id whether the row is new, so merge() of a new member reads first.

select m1_0.id,m1_0.email,m1_0.name from Member m1_0 where m1_0.id=?
insert into Member (email,name,id) values (?,?,?)

Spring Data JPA’s save() calls merge() when the id is already set, so it runs this extra SELECT for every new member. A UUID key also takes 16 bytes instead of 8 and inserts in random index order.

2.5. Choosing an Option

All three options meet the three rules from section 2.1. They differ in what the entity needs and what they cost.

Optionequals() compareshashCode()ProsCons
Generated idid, only when not nullConstant per classWorks for every entity; no extra columnEvery member in one hash slot; two new members are never equal
Business keyemailemail.hashCode()Equal before persist(); matches how people identify a member; good hash spreadNeeds a unique, non-null, stable field
UUID set in Javaidid.hashCode()Id exists from the constructor on; good hash spread16-byte key; merge() runs an extra SELECT for new entities

We use the business key when the entity has one, and the id-based version with a constant hash code otherwise. The UUID option fits when ids are created in the application anyway, for example to return them before the transaction commits.

3. Entity Equality FAQs

3.1. Should equals() Use getClass() or instanceof?

Use instanceof, because Hibernate often returns a proxy instead of the entity, which is a generated subclass of Member that loads the row on first use. The method em.getReference() returns one, and so does a LAZY @ManyToOne. The equals() that IDEs generate compares getClass(), and the proxy class is not Member.

@Override
public boolean equals(Object o) {
  if (this == o) return true;
  if (o == null || getClass() != o.getClass()) return false;
  Member other = (Member) o;
  return id != null && id.equals(other.id);
}
Member proxy = em.getReference(Member.class, id);
String className = proxy.getClass().getName();   // com.howtodoinjava.hibernate.equality.proxy.Member$HibernateProxy
boolean isMember = proxy instanceof Member;       // true
boolean equal = loaded.equals(proxy);             // false
Member$HibernateProxy extends class Member; its own fields id, email and name stay null, the lazy initializer holds id 1 and the real Member is loaded by a SELECT on the first getter call; a table of checks on the proxy: getClass equality false, instanceof Member true, field other.id null, other.getId() returns 1 without SQL, other.getEmail() runs a SELECT and returns the value, Hibernate.getClassLazy returns Member without SQL
A proxy is a subclass with empty fields. The instanceof check and the getters see the real data, whereas getClass() and field access do not.

When the class check must be exact, for example in an entity hierarchy, compare Hibernate.getClassLazy(). Avoid Hibernate.getClass() in equals(), because it loads the proxy.

Class<?> lazyClass = Hibernate.getClassLazy(proxy);   // Member.class, no SQL
boolean before = Hibernate.isInitialized(proxy);      // false
Class<?> realClass = Hibernate.getClass(proxy);       // Member.class, select ... where m1_0.id=?
boolean after = Hibernate.isInitialized(proxy);       // true

3.2. Why Should equals() Call Getters Instead of Fields?

Because Hibernate never fills the proxy’s own fields. It keeps the loaded data in a separate target object and forwards each getter call to it. So other.id reads null on a proxy, while other.getId() returns the id.

Member proxy = em.getReference(Member.class, id);
Long proxyId = proxy.getId();       // 1, no SQL, the proxy knows its id
String email = proxy.getEmail();    // "lokesh@mail.com", select ... where m1_0.id=?
// read with reflection: proxy.id = null, proxy.email = null

The id-based equals() from section 2.2 uses instanceof and getters, and loaded.equals(proxy) returns true for it.

3.3. Can I Use Lombok @Data on a JPA Entity?

No, because @Data generates equals(), hashCode() and toString() over every field, including the generated id and the associations. On a Member with a @OneToMany list of Visit objects, three things break.

members.add(lokesh);
emf.runInTransaction(em -> em.persist(lokesh));
boolean found = members.contains(lokesh);    // false, the new id changed the hash code

members.add(alex);
alex.setName("Alexander");
boolean foundAlex = members.contains(alex);   // false, the new name changed the hash code

alex.addVisit(new Visit("Monday"));
int hash = alex.hashCode();                  // java.lang.StackOverflowError
String text = detached.toString();           // LazyInitializationException

The StackOverflowError comes from the bidirectional association, because Member.hashCode() hashes its visits and each Visit.hashCode() hashes its member again, without end. The toString() call on a member loaded in a closed session tries to load the lazy visits.

org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.howtodoinjava.hibernate.equality.lombok.Member.visits' with key '1' (no session)

Lombok still works when we choose the fields ourselves with @EqualsAndHashCode.

@Entity
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class Member {

  @NaturalId
  @Column(nullable = false)
  @EqualsAndHashCode.Include
  @ToString.Include
  private String email;
}
String text = detached.toString();                                        // Member(email=lokesh@mail.com, name=Lokesh)
boolean equal = detached.equals(new Member("lokesh@mail.com", "Lokesh"));   // true

3.4. Do I Always Need to Override equals() and hashCode()?

No. The default equals() is correct as long as every comparison happens inside one session. Hibernate even suggests leaving them out when we do not need them. Whether we need them depends on where the entities meet.

SituationDefault equals() enough?
Load, change and compare entities inside one transactionYes
Compare a detached entity with one loaded laterNo
Keep entities from several sessions in a Set or as Map keysNo
Add new entities to a HashSet before persist()No, and the hash code must be stable
A class used as a composite id (@IdClass or @EmbeddedId)No, Hibernate requires equals() and hashCode() on the id values

3.5. What Replaced Session.save() and Session.get() in This Example?

The method em.persist() replaces Session.save(), and em.find() replaces session.get(). The old version of this example used save() and get(), but Session.save() no longer exists in Hibernate 7, and em.find() gives the same identity guarantee inside one session.

4. Conclusion

Inside one session, Hibernate returns one object per row, so == works. Across sessions, only our own equals() and hashCode() can tell that two objects are the same member. We compare a business key such as the email when the entity has one, or a non-null id with a constant hash code otherwise, always with instanceof and getters so proxies compare correctly. We never put @Data on an entity.

5. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.