A team added a Product to a HashSet, called em.persist(product), and then checked whether the set contained it. The answer was false. The entity was in the database. It was in the set before the persist call. After the call it had effectively vanished from the set, even though nobody removed it.
The bug is the database-generated ID in hashCode(). Before persist, id is null; hashCode() returns some value based on null. After persist, id is 42; hashCode() returns a completely different value. The entity is now sitting in the wrong bucket of the HashSet. contains() looks in the right bucket, finds nothing, returns false. The entity is lost — not from the database, just from the set — and every operation that relied on that set produces wrong answers.
This is pattern one. There are four more, each equally invisible until the wrong moment. Each one looks like a reasonable implementation when you read it. The problem only shows up at runtime, often under specific conditions that don’t reproduce in unit tests.
Pattern 1: Using the Auto-Generated ID in equals()
This is the most common mistake and the one demonstrated above.
// Bad: ID is null before persist; hashCode changes after INSERT
@Override
public boolean equals(Object o) {
if (!(o instanceof Product)) return false;
Product other = (Product) o;
return Objects.equals(id, other.id);
}
@Override
public int hashCode() {
return Objects.hash(id); // null before persist, 42 after — different bucket
}
Why it fails: Add to a HashSet, persist, check contains() — returns false. The entity is in the wrong bucket. Any bidirectional collection that uses Set is now silently broken.
Fix: Use a business key or a UUID assigned in the constructor — something that is stable before and after persist.
@Column(unique = true, nullable = false, updatable = false)
private final String sku; // assigned at construction, never changes
@Override
public boolean equals(Object o) {
if (!(o instanceof Product)) return false;
Product other = (Product) o;
return Objects.equals(sku, other.getSku()); // stable before and after persist
}
@Override
public int hashCode() {
return Objects.hash(sku); // same before persist, same after persist
}
Pattern 2: Using All Fields in equals()
Lombok’s @Data generates this by default. It looks thorough. It breaks with proxies.
// Bad: all-field equals breaks when one side is a Hibernate proxy
@Override
public boolean equals(Object o) {
if (!(o instanceof Product other)) return false;
return Objects.equals(id, other.id)
&& Objects.equals(name, other.name)
&& Objects.equals(category, other.category); // triggers lazy load on proxy
}
Why it fails: When o is a Hibernate proxy, accessing other.name directly (field access, not getter) reads the proxy’s uninitialised field — which is null even after the proxy is initialised. You get false negatives. If the session is closed, you get LazyInitializationException inside equals(), which is particularly hard to trace because the stack trace points to your equality method, not to the actual cause.
Fix: Use getters, not field access. Use instanceof with the base entity class, not getClass(). Limit equality to the stable identifier field only.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Product other)) return false; // instanceof handles proxies
return getSku() != null && Objects.equals(getSku(), other.getSku()); // getter forces init
}
Pattern 3: Using a UUID Generated by Hibernate (Not by the Constructor)
Teams that know about the ID problem sometimes switch to a UUID primary key and assume that solves it. It does — but only if the UUID is assigned by the Java constructor, not by Hibernate’s generator.
// Bad: UUID generated by Hibernate is null until first flush
@Id
@GeneratedValue(generator = "uuid2")
@GenericGenerator(name = "uuid2", strategy = "uuid2")
private UUID id;
@Override
public int hashCode() {
return Objects.hash(id); // null until Hibernate assigns it at flush time
}
Why it fails: The Hibernate UUID generator assigns the value at flush time, not at object construction. Between new Entity() and the first flush, id is null. Same bucket problem as pattern 1, just with a UUID instead of a Long.
Fix: Assign the UUID in the constructor. Use it as a separate natural-ID field if you still want a Long surrogate PK, or use it as the PK itself.
@Column(unique = true, nullable = false, updatable = false)
private final UUID naturalId = UUID.randomUUID(); // assigned now, not at flush
@Override
public boolean equals(Object o) {
if (!(o instanceof Order other)) return false;
return Objects.equals(naturalId, other.getNaturalId());
}
@Override
public int hashCode() {
return Objects.hash(naturalId); // stable from construction through entire lifecycle
}
Pattern 4: Using Objects.hash() on Lazy Associations
This one is subtle. The developer knows not to use the ID. They pick a business key. The business key happens to include a lazy association.
// Bad: category is a lazy @ManyToOne — hashCode() triggers a SELECT
@Override
public int hashCode() {
return Objects.hash(name, category); // category.hashCode() initialises the proxy
}
Why it fails: Every hashCode() call triggers proxy initialisation on category, which triggers a SELECT if the session is open, or a LazyInitializationException if not. Adding entities to a HashSet in a context where the session is closed — a mapper, a REST controller, a DTO converter — becomes a landmine. The symptom looks like a lazy-loading problem; the cause is hashCode().
Fix: Keep equals() and hashCode() to scalar fields only. Never include associations. If equality must span relationships, that’s a domain model problem, not an annotation problem.
Pattern 5: Using a Business Key from a Mutable Column
The developer correctly avoids the ID, chooses a business key, and uses email. Then the application gets a GDPR right-to-be-forgotten request, and users need to be able to change their email address.
// Bad: email can change; hashCode changes with it
@Override
public boolean equals(Object o) {
if (!(o instanceof User other)) return false;
return Objects.equals(getEmail(), other.getEmail());
}
@Override
public int hashCode() {
return Objects.hash(getEmail()); // user changes email → different bucket → lost from Set
}
Why it fails: Any Set or HashMap holding this entity is silently corrupted after the email changes. An entity that was in a Set before the update cannot be found in that same set after the update. The data is correct in the database; the problem is entirely in the collection’s hash bucket.
Fix: Business keys for equality must be truly immutable. If the value can be changed by application logic — email, username, phone number — it is not safe as a business key. Use a UUID assigned at construction instead.
Under the Hood: How EntityKey Differs from equals()
It is worth understanding that Hibernate’s internal identity tracking uses EntityKey — not your equals() method. EntityKey is constructed from the entity class and the database identifier. It is what the identity map (first-level cache) uses to guarantee one managed instance per row per session.
Your equals() implementation has no effect on how Hibernate tracks managed entities internally. What it affects is how your entities behave in Java collections — Set, HashMap, List.contains(), List.indexOf() — and whether bidirectional collection synchronisation works correctly. Two entities can represent the same database row, be equal by EntityKey inside Hibernate’s session, and still fail equals() if you implemented it incorrectly. Hibernate’s guarantee is about persistence; your equality implementation is about collection behaviour.
Production Scenario: Wrong equals() Causing Duplicate Inserts
A billing service accumulated line items in a Set<LineItem> and called invoice.addLineItem(item) for each one before persisting the invoice. The Set was supposed to prevent duplicates. After switching from an in-memory H2 test database to PostgreSQL in staging, the service started inserting duplicate line items on orders with high item counts.
The root cause: equals() used the generated ID. In H2 with hbm2ddl.auto=create-drop, the IDs were assigned predictably and the test data never triggered the collision. In PostgreSQL with sequences, IDs were batched differently. The Set was accepting duplicates because all new (pre-persist) items had id = null, making them all equal to each other and collapsing them to one entry — opposite of the expected deduplication.
The fix was switching to a UUID assigned in the LineItem constructor. The set correctly rejected duplicates, the billing data was accurate, and the test suite was updated to actually assert collection behaviour across a persist boundary.
See Also
- 📘 The Hibernate 7 Persistence Context — why transient entities have null IDs and what happens at persist()
- 📘 LazyInitializationException Debugging Walkthrough — how proxy access inside equals() causes the exception
- 📘 @NaturalId in Hibernate 7 — using business keys for cache-optimised lookups alongside surrogate PKs
- 📘 @OneToMany Done Right — Set vs List and why hashCode stability matters for bidirectional sync
Frequently Asked Questions
Why can’t I just use the database ID in hashCode()?
Because the ID is null before the entity is persisted, which means the hash code changes after persist. Any collection that was holding the entity before persist will be silently corrupted — contains() returns false, remove() doesn’t find it, iteration may skip it. The entity is in the wrong bucket after the hash changes.
Should I mark equals() and hashCode() as final?
Yes. Hibernate creates ByteBuddy subclasses for proxies. Marking the methods final prevents accidental override by those subclasses. It is a defensive measure that costs nothing and prevents a hard-to-diagnose category of proxy-equality bugs.
Does Lombok’s @EqualsAndHashCode work with Hibernate entities?
Only with explicit configuration. The default generates an all-fields implementation including the database ID, which causes pattern 1. Use @EqualsAndHashCode(onlyExplicitlyIncluded = true) and annotate only your stable business key field with @EqualsAndHashCode.Include. Avoid @Data on entities entirely — it generates too many things (including toString() that can trigger lazy loads) without Hibernate-specific safety considerations.
Conclusion
All five patterns share the same underlying problem: the equality contract is broken at some point in the entity lifecycle. The fix is always the same principle — choose a stable identifier that is assigned at object construction, never changes, and is unique within the entity type. For entities with a natural business key (SKU, ISO code, slug), use that. For entities without one, assign a UUID in the constructor. Keep equals() and hashCode() to scalar fields, use getters not field access, and mark both methods final. Do that consistently and the HashSet-after-persist bug, the lazy-load-inside-equals bug, and the mutable-key corruption bug all disappear at once.