Pavan Rangani

HomeBlogequals() and hashCode(): The Contract That Breaks Your HashMap

equals() and hashCode(): The Contract That Breaks Your HashMap

By Pavan Rangani · August 18, 2026 · Java & Spring

equals() and hashCode(): The Contract That Breaks Your HashMap

Here is a bug that has confused every Java developer at least once. You create an object, put it in a HashSet, then create an equal object and ask the set whether it contains it. The set says no. The two objects are equal by every field, and yet the collection cannot find one using the other. The cause is almost always a broken equals and hashCode contract, and it is worth understanding properly because it hides in ordinary-looking code and only surfaces when a collection is involved.

Why both methods exist

Java gives every object two methods that hash-based collections depend on. equals() answers “are these two objects the same value?” and hashCode() returns an integer that a HashMap or HashSet uses to decide which bucket the object belongs in. These two work together, and the collection trusts that they agree.

The way a hash-based collection finds an object is a two-step dance. First it computes the object’s hash code to jump straight to the right bucket, skipping the rest. Then, within that bucket, it uses equals() to find the exact match. This is what makes a HashMap lookup fast: the hash narrows the search to one small bucket instead of scanning everything. But it only works if equal objects produce equal hash codes, because otherwise they land in different buckets and the equals() step never gets a chance to run.

The contract, in plain terms

The rule that ties them together is short and absolute: if two objects are equal, they must have the same hash code. The reverse is not required — two unequal objects may happen to share a hash code, which is a harmless collision the collection handles. But equal objects with different hash codes is a broken contract, and it breaks silently.

This is why overriding equals() without also overriding hashCode() is such a common and damaging mistake. You have told Java that two objects with the same fields are equal, but you left the default hashCode() in place, which is based on object identity — so two “equal” objects get two different hash codes, land in two different buckets, and the collection concludes they are different after all. It is the same class of quiet, only-shows-up-under-load bug as the N+1 query problem — invisible in a small test, painful in production.

// BROKEN: equals overridden, hashCode forgotten
class Point {
    final int x, y;
    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;
    }
    // no hashCode() -> uses identity hash -> HashSet cannot find equal points
}

var set = new HashSet<Point>();
set.add(new Point(1, 2));
set.contains(new Point(1, 2));   // false! the object is "lost" in the set

That false is the whole bug. The point is in the set, but the set looked in the wrong bucket and never found it. Add IDE-generated or record-based implementations and it just works — the fix is never clever, only remembered.

Getting it right

The correct version overrides both, and crucially uses the same fields in each. Whatever fields make two objects equal must be exactly the fields that feed the hash code — no more, no less. Using different fields in the two methods reintroduces the same broken-contract bug in a subtler form.

@Override public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Point p)) return false;
    return x == p.x && y == p.y;
}
@Override public int hashCode() {
    return Objects.hash(x, y);   // same fields as equals()
}

Objects.hash() is the standard, readable way to combine fields, and it is good enough for the overwhelming majority of code. You rarely need to hand-roll a hash function; reaching for a clever one is usually a sign you are optimising something that was never slow.

Records make the problem disappear

Modern Java has a feature that removes this entire category of bug: the record. A record automatically generates equals(), hashCode(), and toString() from its components, correctly and consistently, so the contract is impossible to break. For an immutable data carrier — which is exactly the kind of object you put in a set or use as a map key — a record is almost always the right choice, and it is the modern default worth reaching for.

record Point(int x, int y) {}   // equals + hashCode generated, contract guaranteed

If you cannot use a record — the class is mutable, or extends something — then either let your IDE generate both methods together, or use a library helper, but never write one without the other. The discipline of “override them as a pair, from the same fields” is the entire lesson.

The mutability trap

One last hazard worth naming, because it produces the most baffling version of this bug. If you use a mutable object as a key in a HashMap and then change one of the fields that its hash code depends on, the object’s hash code changes — but it is already sitting in a bucket chosen by its old hash. Now the map has an entry it can never find again, because a lookup computes the new hash and searches the new bucket, which is empty.

The rule that follows is to use immutable objects as map keys and set elements. If the fields that define equality can never change, the hash code can never change, and the object can never get lost. This is one more reason records — which are immutable by design — fit this role so well. When you find an entry that is definitely in a map but cannot be retrieved, a mutated key is almost always the reason, and it is why keys should be things that do not change out from under the collection holding them. It is exactly the kind of subtle, intermittent bug that a production profiling recording helps you catch, because it only appears with real data in a real collection.

← Back to all articles