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 returns true.
  • Symmetric: x.equals(y) returns true if and only if y.equals(x) returns true.
  • Transitive: if x.equals(y) and y.equals(z), then x.equals(z).
  • Consistent: if the object doesn’t change, multiple calls must return the same result.
  • With null: x.equals(null) always returns false.

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 == o is a shortcut when both references point to the same object.
  • instanceof Customer other uses pattern matching (Java 16+). This expression returns false when o is null, so the “with null” property is guaranteed without a separate check.
  • Objects.equals handles null fields safely.
  • Objects.hash computes the hash from exactly the fields used in equals().

The class is declared final. The reason will become clear in section 6.

3. The Contract of hashCode()

hashCode() has three rules:

  1. Multiple calls on the same unchanged object must return the same value.
  2. If a.equals(b), then a.hashCode() == b.hashCode(). This is the most important rule.
  3. 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 final or use record. This is the simplest approach.
  • Use getClass() != o.getClass() instead of instanceof. Then Point and ColorPoint are never equal. The contract holds, but a subclass that adds no fields (like a proxy) won’t equal its superclass either.
  • Prefer composition: let ColorPoint contain a Point instead 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 a HashMap or an element in a HashSet. 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 List instead of an array.
  • A BigDecimal component 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 @Id either, since id is null before persist, making the hash change after save. Common practice is to compare by an immutable business key. Another way is to compare id when non-null, and make hashCode() return a class-level constant, like getClass().hashCode().
  • Lombok @Data on entities or bidirectional relationships: this annotation generates equals()/hashCode() using all fields. This can cause StackOverflowError when 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 overriding equals(Object);
  • inheritance breaking symmetry;
  • mutable fields in keys;
  • traps of BigDecimal and 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.

javaequalshashcodecollectionsrecord