equals() and hashCode(): Contract and Common Pitfalls
1. Overview
equals() and hashCode() look simple, but get them wrong and HashSet will contain duplicates while HashMap will “lose” keys. These errors usually don’t throw exceptions—the program still runs, just incorrectly.
This article explains the contract that Java imposes on these two methods. Then we’ll go through common pitfalls, each with an example to reproduce it. After reading, you’ll know how to implement both methods correctly, when to use record, and how to write tests to verify them.
2. The Contract of equals()
By default, Object.equals() compares references—that is, this == obj. When you override it, you must maintain five properties for all non-null references:
- Reflexive:
x.equals(x)always returnstrue. - Symmetric:
x.equals(y)returnstrueif and only ify.equals(x)returnstrue. - Transitive: if
x.equals(y)andy.equals(z), thenx.equals(z). - Consistent: if the object doesn’t change, multiple calls must return the same result.
- With null:
x.equals(null)always returnsfalse.
A correct implementation for a Customer class looks like this:
import java.util.Objects;
public final class Customer {
private final String email;
private final String name;
public Customer(String email, String name) {
this.email = email;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return Objects.equals(email, other.email)
&& Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(email, name);
}
}
Breaking it down:
this == ois a shortcut when both references point to the same object.instanceof Customer otheruses pattern matching (Java 16+). This expression returnsfalsewhenoisnull, so the “with null” property is guaranteed without a separate check.Objects.equalshandlesnullfields safely.Objects.hashcomputes the hash from exactly the fields used inequals().
The class is declared final. The reason will become clear in section 6.
3. The Contract of hashCode()
hashCode() has three rules:
- Multiple calls on the same unchanged object must return the same value.
- If
a.equals(b), thena.hashCode() == b.hashCode(). This is the most important rule. - Different objects may have the same hash code (collision). However, good hash distribution helps collections run faster.
The reverse of rule 2 is not true. Same hash code does not mean equality.
To understand why rule 2 matters, consider how HashMap finds a key. First, it uses hashCode() to select a bucket. Then it uses equals() to compare elements in that bucket. If two objects are “equal” but have different hashes, they sit in different buckets, and equals() is never called.
4. Error 1: Overriding equals() and Forgetting hashCode()
This is the classic mistake. Suppose you remove hashCode() from the class in section 2:
public class Customer {
private final String email;
public Customer(String email) { this.email = email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return email.equals(other.email);
}
// hashCode() missing
}
Set<Customer> customers = new HashSet<>();
customers.add(new Customer("an@example.com"));
customers.add(new Customer("an@example.com"));
System.out.println(customers.size());
System.out.println(customers.contains(new Customer("an@example.com")));
2
false
Two objects are equal according to equals(). But hashCode() is still the default implementation from Object, which typically differs for each instance. Result: HashSet contains two “duplicate” elements, and contains() returns false. This outcome is almost certain. It’s rare for two default hashes to collide, making the error hard to reproduce consistently.
Note: IDEs and tools like SonarQube warn about this error. Don’t disable that warning.
5. Error 2: Overloading Instead of Overriding
This error is much harder to spot:
public class Order {
private final long id;
public Order(long id) { this.id = id; }
public boolean equals(Order other) { // WRONG: parameter is Order, not Object
return other != null && id == other.id;
}
@Override
public int hashCode() { return Long.hashCode(id); }
}
Order a = new Order(1);
Order b = new Order(1);
System.out.println(a.equals(b)); // calls equals(Order)
System.out.println(List.of(a).contains(b)); // calls equals(Object)
true
false
equals(Order) is a new method (overload), not an override of equals(Object). Collections always call equals(Object), so they still use Object’s reference comparison.
The prevention is simple: always use @Override. If you add @Override to the method above, the compiler will report an error immediately.
6. Error 3: Breaking Symmetry Through Inheritance
Consider a Point class and a subclass ColorPoint that adds a color field:
public class Point {
protected final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
}
public class ColorPoint extends Point {
private final String color;
public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint cp)) return false;
return super.equals(cp) && color.equals(cp.color);
}
@Override
public int hashCode() { return Objects.hash(x, y, color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");
System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
true
false
Symmetry is broken. Moreover, p.equals(cp) is true but the two objects have different hashes, violating rule 2 from section 3.
There is no way to add meaningful fields to a subclass while preserving the contract with instanceof. You have three choices:
- Declare the class
finalor userecord. This is the simplest approach. - Use
getClass() != o.getClass()instead ofinstanceof. ThenPointandColorPointare never equal. The contract holds, but a subclass that adds no fields (like a proxy) won’t equal its superclass either. - Prefer composition: let
ColorPointcontain aPointinstead of extending it.
7. Error 4: Mutable Fields in Keys
hashCode() is computed when an object is added to a HashSet. If a field used in the hash calculation changes later, the object remains in the old bucket:
public class Product {
private String sku;
public Product(String sku) { this.sku = sku; }
public void setSku(String sku) { this.sku = sku; }
@Override
public boolean equals(Object o) {
return o instanceof Product p && Objects.equals(sku, p.sku);
}
@Override
public int hashCode() { return Objects.hashCode(sku); }
}
Product product = new Product("SKU-1");
Set<Product> catalog = new HashSet<>();
catalog.add(product);
product.setSku("SKU-2");
System.out.println(catalog.contains(product));
System.out.println(catalog.remove(product));
System.out.println(catalog.size());
false
false
1
The object is still in the set, but you can’t find it or remove it. Result: a small memory leak and incorrect logic.
Note: Only use immutable fields in
equals()/hashCode(). If you must use mutable fields, don’t modify an object while it’s a key in aHashMapor an element in aHashSet. The correct approach is to remove it, modify it, then add it back.
8. Error 5: Traps of BigDecimal and Arrays
Some types in the JDK have equals() behavior you might not expect.
8.1. BigDecimal Compares Scale
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b));
System.out.println(a.compareTo(b) == 0);
false
true
BigDecimal.equals() treats 1.0 and 1.00 as different because they have different scales. If your Money class uses BigDecimal in equals(), normalize the scale first. For example, call amount.setScale(2, RoundingMode.HALF_UP) in the constructor. Note that stripTrailingZeros() also normalizes, but it can produce negative scale, for example with 100.
8.2. Arrays Compare by Reference
Arrays don’t override equals(). So new int[]{1, 2}.equals(new int[]{1, 2}) returns false. When a field is an array, use Arrays.equals() and Arrays.hashCode(). This trap also applies to record, as you’ll see in the next section.
9. Using Records to Avoid Most Errors
Starting with Java 16, record auto-generates equals(), hashCode(), and toString() based on all components:
public record OrderLine(String productSku, int quantity) {}
var line1 = new OrderLine("SKU-1", 2);
var line2 = new OrderLine("SKU-1", 2);
System.out.println(line1.equals(line2));
System.out.println(line1.hashCode() == line2.hashCode());
true
true
record is a final class, its fields are final, and the two methods are generated correctly. Errors from sections 4, 5, and 6 disappear. Error from section 7 almost disappears, except when a component is a mutable object like a List.
Two points to remember when using record:
- An array component is still compared by reference. Use
Listinstead of an array. - A
BigDecimalcomponent still has the scale trap. Normalize it in the compact constructor:
public record Money(BigDecimal amount, String currency) {
public Money {
amount = amount.setScale(2, RoundingMode.HALF_UP);
}
}
With this declaration, new Money(new BigDecimal("1.0"), "VND") equals new Money(new BigDecimal("1.00"), "VND"), because both are normalized to scale 2.
10. Testing equals() and hashCode()
You should have tests for the main behavior. Here’s a test with JUnit 5 and AssertJ for the Customer class from section 2:
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerTest {
@Test
void equalCustomers_shouldHaveSameHashCode_andDeduplicateInSet() {
var a = new Customer("an@example.com", "An");
var b = new Customer("an@example.com", "An");
assertThat(a).isEqualTo(b);
assertThat(b).isEqualTo(a);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
Set<Customer> set = new HashSet<>(Set.of(a));
assertThat(set).contains(b);
}
@Test
void customer_shouldNotEqualNullOrOtherType() {
var a = new Customer("an@example.com", "An");
assertThat(a).isNotEqualTo(null);
assertThat(a).isNotEqualTo("an@example.com");
}
}
The first test checks symmetry, the rule that equal objects have equal hashes, and real behavior in HashSet. The second test checks null and type mismatch.
Writing by hand for every case is tedious. The EqualsVerifier library verifies the entire contract in one line:
<dependency>
<groupId>nl.jqno.equalsverifier</groupId>
<artifactId>equalsverifier</artifactId>
<version>3.16.1</version>
<scope>test</scope>
</dependency>
@Test
void equalsContract() {
EqualsVerifier.forClass(Customer.class).verify();
}
If you forget a field in hashCode(), use non-final fields, or use a non-final class with instanceof, the test will fail with a message pointing out which rule was violated.
11. When NOT to Override
Not every class needs to override equals()/hashCode():
- Classes with natural identity per instance, like
Thread,PaymentService, or Spring beans. Two services are different objects, and reference comparison is correct. - JPA entities: avoid using all fields. Don’t naively use
@Ideither, sinceidisnullbefore persist, making the hash change after save. Common practice is to compare by an immutable business key. Another way is to compareidwhen non-null, and makehashCode()return a class-level constant, likegetClass().hashCode(). - Lombok
@Dataon entities or bidirectional relationships: this annotation generatesequals()/hashCode()using all fields. This can causeStackOverflowErrorwhen entities reference each other, or trigger unwanted lazy loading.
12. Conclusion
The contract between equals() and hashCode() revolves around one core rule: two equal objects must have the same hash code. Most real-world errors come from five causes:
- forgetting to override
hashCode(); - overloading
equals(Order)instead of overridingequals(Object); - inheritance breaking symmetry;
- mutable fields in keys;
- traps of
BigDecimaland arrays.
In new code, prefer record for value objects. For regular classes, declare them final, always use @Override, and use only immutable fields. Finally, add a test with EqualsVerifier to let tooling check the contract for you.