Why overriding hashCode also means overriding equals

Java lets you override equals and hashCode independently, but hash-based collections don’t work with just one of them. The JDK ties the two together with a contract: the Object.hashCode Javadoc requires that if two objects are equal according to equals(), then calling hashCode() on each “must produce the same integer result.” And Object.equals, which you inherit by default, compares identity — the same reference, per the Object.equals Javadoc. So when you start overriding one of these two, you’re in value-equality territory, and the other one has to come along for the same object.

This post walks through all three configurations — both overridden, only hashCode, only equals — on a small value object, and shows what a HashSet and HashMap do in each case.

Both methods: the contract working

Here a Point overrides both. equals says two points are equal when their coordinates match; hashCode is derived from the same fields, so equal points hash the same.

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;

class Point {
    final int x, y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @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 31 * x + y;
    }

    @Override
    public String toString() {
        return "(" + x + ", " + y + ")";
    }

    public static void main(String[] args) {
        Point a = new Point(1, 2);
        Point b = new Point(1, 2); // logically identical, different object

        System.out.println("a.equals(b)      = " + a.equals(b));
        System.out.println("a.hashCode()     = " + a.hashCode());
        System.out.println("b.hashCode()     = " + b.hashCode());

        HashSet<Point> set = new HashSet<>();
        set.add(a);
        set.add(b); // logically same point
        System.out.println("\nHashSet size after adding a and b: " + set.size());
        System.out.println("set = " + set);

        Map<Point, String> map = new HashMap<>();
        map.put(a, "origin");
        System.out.println("\nmap.get(a) = " + map.get(a));
        System.out.println("map.get(b) = " + map.get(b));
    }
}

Running it:

a.equals(b)      = true
a.hashCode()     = 33
b.hashCode()     = 33

HashSet size after adding a and b: 1
set = [(1, 2)]

map.get(a) = origin
map.get(b) = origin

Both Point(1, 2) instances are equal and hash to 33, so the HashSet treats the second add as a duplicate (size stays 1), and map.get(b) finds the entry that map.put(a, ...) stored. That’s the intended behavior: hashCode routes the lookup to the right bucket, and equals confirms the match within it.

{VIDEO:both}

hashCode only: same bucket, still two objects

Now the same class, but with equals left out — hashCode alone.

import java.util.HashSet;

class Ticket {
    final int id;

    Ticket(int id) {
        this.id = id;
    }

    // hashCode overridden: two tickets with the same id hash the same...
    @Override
    public int hashCode() {
        return id;
    }
    // ...but equals is NOT overridden, so Object's identity comparison stays.

    @Override
    public String toString() {
        return "Ticket(" + id + ")";
    }

    public static void main(String[] args) {
        Ticket a = new Ticket(42);
        Ticket b = new Ticket(42); // same id, different object

        System.out.println("a.hashCode() = " + a.hashCode());
        System.out.println("b.hashCode() = " + b.hashCode());
        System.out.println("a.equals(b)  = " + a.equals(b));
        System.out.println("a == b       = " + (a == b));

        HashSet<Ticket> set = new HashSet<>();
        set.add(a);
        set.add(b);
        System.out.println("\nHashSet size after adding both: " + set.size());
        System.out.println("set = " + set);
    }
}
a.hashCode() = 42
b.hashCode() = 42
a.equals(b)  = false
a == b       = false

HashSet size after adding both: 2
set = [Ticket(42), Ticket(42)]

Both tickets land in the same bucket — identical hash codes, 42 — but the set still ends up with two entries. That’s the part hashCode alone can’t fix: it only gets the collection to the right place. Once the two objects share a bucket, HashSet settles the question with equals, and Ticket still has Object’s identity version, so a.equals(b) is false and both objects are kept. A hash code that says “same group” without an equals that says “same value” is just a promise the collection can’t keep.

{VIDEO:hashcode-only}

equals only: the classic violation

The other half of the pairing, and the more dangerous one, is overriding equals and not hashCode:

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;

class Ticket {
    final int id;

    Ticket(int id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Ticket t)) return false;
        return id == t.id;
    }
    // hashCode is NOT overridden -> falls back to Object.hashCode(),
    // which is based on object identity, so different objects hash differently.

    @Override
    public String toString() {
        return "Ticket(" + id + ")";
    }

    public static void main(String[] args) {
        Ticket a = new Ticket(42);
        Ticket b = new Ticket(42); // equal per equals()...

        System.out.println("a.equals(b) = " + a.equals(b));
        System.out.println("a.hashCode() = " + a.hashCode());
        System.out.println("b.hashCode() = " + b.hashCode());

        HashSet<Ticket> set = new HashSet<>();
        set.add(a);
        set.add(b);
        System.out.println("\nHashSet size after adding both: " + set.size());
        System.out.println("set = " + set);

        Map<Ticket, String> map = new HashMap<>();
        map.put(a, "reserved");
        System.out.println("\nmap.get(a) = " + map.get(a));
        System.out.println("map.get(b) = " + map.get(b));
    }
}
a.equals(b) = true
a.hashCode() = 2125039532
b.hashCode() = 312714112

HashSet size after adding both: 2
set = [Ticket(42), Ticket(42)]

map.get(a) = reserved
map.get(b) = null

This is a direct breach of the contract. a.equals(b) is true, but the two hash codes differ — with hashCode still Object’s, the two distinct objects get distinct, identity-based values. The set now stores both “equal” tickets (size 2), and in the map the worst case shows up: map.get(a) returns "reserved", but map.get(b) — the same logical key — returns null, because the lookup went to a different bucket and never even saw the stored entry.

Notice the asymmetry between the previous example and this one. With only hashCode, the objects are in the same bucket and equals correctly keeps them apart — the contract is satisfied, it’s just that equality is identity-based and maybe not what you wanted. With only equals, you’ve violated the contract itself: equal objects with different hash codes, and every HashSet/HashMap operation on that type can silently misbehave.

Takeaway

All three runs use the same shape of data — two objects that logically represent the same thing — and the collection’s behavior changes with each override choice. The contract is one-directional: equal objects must have equal hash codes, but equal hash codes do not require equal objects (that’s a collision, and equals is what resolves it). So the practical rule is simple: hashCode should be computed from the same fields that equals compares, and the moment you override one, write the other.