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.
| Check | Question it answers | Default from Object |
|---|---|---|
| a == b | Do 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.

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.

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.
| Option | equals() compares | hashCode() | Pros | Cons |
|---|---|---|---|---|
| Generated id | id, only when not null | Constant per class | Works for every entity; no extra column | Every member in one hash slot; two new members are never equal |
| Business key | email.hashCode() | Equal before persist(); matches how people identify a member; good hash spread | Needs a unique, non-null, stable field | |
| UUID set in Java | id | id.hashCode() | Id exists from the constructor on; good hash spread | 16-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

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.
| Situation | Default equals() enough? |
|---|---|
| Load, change and compare entities inside one transaction | Yes |
| Compare a detached entity with one loaded later | No |
| Keep entities from several sessions in a Set or as Map keys | No |
| 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
- Jakarta Persistence 3.2 specification: Entity Operations overview
- Hibernate ORM 7.4 User Guide: Implementing equals() and hashCode()
- Hibernate class JavaDoc (Hibernate ORM 7.4)
- Object JavaDoc (Java 25)
- Lombok @EqualsAndHashCode
Happy Learning !!