Skip to main content

equals() and hashCode() in Java: The Contract, Real Bugs and How Records Change It

Why Java needs both equals() and hashCode(), shown by breaking them: a HashSet holding duplicates, a constant hash costing 2,002,999 comparisons for 2,000 lookups, a mutated key that get() and remove() cannot find, record edge cases, and the JPA entity id trap. Every output is from a JDK 25 run and a committed transcript.

Two methods, equals() and hashCode(), look like boilerplate until the day a HashSet holds the same book twice, or a HashMap shows you an entry in a loop and then swears it is not there. This post starts from what the two methods are for, then breaks the rules one at a time so you can see the exact symptom each break produces. Every output below was printed by code you can run. You do not need to know how HashMap works to start. By the end you will, well enough to predict these bugs before they happen.
Tested against: JDK 25.0.5+8 (Temurin LTS), JUnit Jupiter 5.11.0, Maven 3.9.11. All 13 tests pass (output/08-tests.txt). The JPA section is a plain-Java simulation with no Hibernate on the classpath, and says so where it matters.

What equals() and hashCode() are each for

equals() answers one question: “do these two objects represent the same thing?” Out of the box it means “are they the very same object in memory”, which is rarely what you want for a book, an order or a customer. So you override it to compare the fields that make two objects the same thing. hashCode() answers a different, cheaper question: “which shelf should I look on?” HashMap and HashSet keep their entries in buckets (shelves). To find a key, they call hashCode() to pick a bucket and only then call equals() on the few entries inside it. That is why lookups are fast, and also why the two methods must agree.
hashCode() overriddenbook a (isbn 1)book b (isbn 1)hash = 7hash = 7bucket 7holds a; b arrivesa.equals(b) is trueso b is a duplicatehashCode() not overridden (identity hash)book a (isbn 1)book b (isbn 1)hash = 3hash = 9bucket 3bucket 9Different buckets: equals() is never called,so the duplicate goes in unnoticed.
The top row is the bucket story. The bottom row is what happens when you override only equals(): two books that are equal land on different shelves, and nobody ever asks equals() the question. The rule that follows, and that the rest of this post tests, is that equal objects must produce equal hash codes.

Override only equals() and the set holds duplicates

This is the classic bug, and it is quiet: no exception, no warning. Here is a class with equals() but no hashCode(), from MissingHashCodeDemo.java:
static final class Broken {
    final String isbn;
    Broken(String isbn) { this.isbn = isbn; }
    @Override public boolean equals(Object o) { return o instanceof Broken b && isbn.equals(b.isbn); }
    // hashCode() NOT overridden: Object.hashCode() is identity-based
}
And what a HashSet and a HashMap do with two equal instances, captured in output/02-missing-hashcode.txt:
== equals() without hashCode() ==
a.equals(b)                 = true
a.hashCode() == b.hashCode() = false
HashSet size after adding a then b = 2
HashSet.contains(new equal key)    = false
HashMap.get(equal key)             = null
a.equals(b) is true, yet the set has size 2, contains of a fresh equal key is false, and the map returns null for a key you just stored. Adding hashCode() fixes all three:
== with hashCode() ==
HashSet size after adding c then d = 1
HashSet.contains(new equal key)    = true
The fingerprint of this bug: “it works in my unit test, fails in production.” The default hashCode() comes from the object’s identity, so a lookup with a different instance only succeeds if two identity hashes happen to land in the same bucket. The run above is not a coincidence of seeds; it is what you should expect almost every time.
Going deeper: why not just return the same number? A constant hashCode() is legal, because the contract only says equal objects need equal hashes, not that unequal ones need different hashes. It is also a performance disaster, because every key lands in one bucket. I put 2,000 keys in a map with return 42 and then looked each one up, counting calls to equals() (class from MissingHashCodeDemo.java):
static final class Constant {
    static long equalsCalls;
    final int id;
    Constant(int id) { this.id = id; }
    @Override public boolean equals(Object o) { equalsCalls++; return o instanceof Constant c && id == c.id; }
    @Override public int hashCode() { return 42; }
}
== a legal but useless hashCode(): return 42 ==
keys                                  = 2000
equals() calls for 2000 gets, constant hash = 2002999
equals() calls for 2000 gets, good hash     = 2000
Two thousand lookups cost 2,002,999 comparisons, close to n²/2, against exactly 2,000 for a well-spread hash. These keys are not Comparable, so the tree-shaped bucket that HashMap can fall back to has nothing to order them by; I did not inspect the bucket structure itself, only the counted calls. That is the whole point of hashing, gone.

The five rules of the equals() contract

equals() has to behave like a sensible “same thing” relation. The Javadoc for Object.equals states five rules; the third column is what breaks when you violate one.
RulePlain meaningSymptom when broken
Reflexivex.equals(x) is truelist.contains(x) false for an element you just added
Symmetricx.equals(y) exactly when y.equals(x)contains gives different answers depending on which side you search
Transitivex=y and y=z implies x=zTwo items are “equal” to a third but not to each other
ConsistentSame answer on every call if nothing changedFlaky behaviour you cannot reproduce
Non-nullx.equals(null) is false, never an exceptionNullPointerException inside a collection
A correct equals() is short. The shape below is the one every other example in this post builds on; it is Point from ContractDemo.java:
static 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 Objects.hash(x, y); }
}
The first three lines of equals are the identity shortcut, the type check (which also handles null, because null instanceof Point is false) and a cast in one step. Then it compares the same fields that hashCode() uses. If you compare a field in equals(), hash it too; if you do not compare it, do not hash it.

Subclasses are where the contract breaks

Adding a field in a subclass is the standard way to break symmetry. Say ColorPoint extends Point and includes its colour in equals():
static class ColorPoint extends Point {
    final String color;
    ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
    @Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint c)) return false;   // a plain Point is never equal
        return super.equals(o) && color.equals(c.color);
    }
    @Override public int hashCode() { return Objects.hash(super.hashCode(), color); }
}
A plain point and a red point at the same coordinates now disagree about each other. A “lenient” fix, where a coloured point ignores colour when compared with a plain one, trades that bug for a transitivity bug. Both are in output/01-contract.txt:
== Symmetry broken by instanceof + extra field ==
plain.equals(red) = true
red.equals(plain) = false
List.of(plain).contains(red) = false
List.of(red).contains(plain) = true
== Transitivity broken by the lenient fix ==
r.equals(plain) = true
plain.equals(b) = true
r.equals(b)     = false
Read the transitivity block carefully: the red point equals the plain point, the plain point equals the blue point, yet red does not equal blue. And the symmetry block shows the practical damage: List.of(plain).contains(red) is false while List.of(red).contains(plain) is true, so whether your code “finds” an object depends on which one happened to be in the list.
There is no way to add a field to an instantiable class and keep equals() correct across the hierarchy if the subclass’s equals() uses the new field and the two types can meet. The options that work are: make the class final (or a record), use composition (a ColorPoint that has a Point instead of being one), or compare with getClass(), which gives up substitutability.
Going deeper: getClass() versus instanceof, and the JDK’s own symmetry bug instanceof keeps subclasses substitutable but is what lets the asymmetry above happen. getClass() != o.getClass() makes equality strict (a subclass instance never equals a superclass instance), which restores symmetry and transitivity but means a framework-generated proxy subclass of your entity is never equal to the real one. Pick on purpose, and document it. The JDK itself breaks symmetry once, and its Javadoc admits it: java.sql.Timestamp extends java.util.Date and adds nanoseconds. The Timestamp class description says its equals(Object) is not symmetric with Date.equals(Object), and the run confirms it (output/04-jdk-symmetry.txt, from SymmetryDemo.java):
date.equals(ts) = true
ts.equals(date) = false
date.getTime() == ts.getTime() = true
The consistency rule is the easiest to break by accident, for example by comparing against a clock, a random value or a lazily loaded field. The Flaky class in ContractDemo.java answers true, false, true, false for the same two arguments; see the last block of output/01-contract.txt.

Mutable keys: the entry you can see but cannot find

Put an object in a HashMap and the map files it on the shelf its hash code pointed to at that moment. If you then change a field that feeds hashCode(), the object does not move. Later lookups compute the new hash, walk to a different shelf, and find nothing.
1. put(acc) while owner = “asha”acc (asha)bucket for “asha”bucket for “ankur”2. acc.owner = “ankur”, then get(acc)acc (ankur)still holds accempty: not foundThe lookup walks past the shelf that holds the entry. Nothing moved the entry, so it is stranded.
The first shelf still holds the entry, but the lookup goes to the second. Here is the class from MutableKeyDemo.java:
static final class Account {
    String owner;
    Account(String owner) { this.owner = owner; }
    @Override public boolean equals(Object o) { return o instanceof Account a && owner.equals(a.owner); }
    @Override public int hashCode() { return owner.hashCode(); }
    @Override public String toString() { return "Account(" + owner + ")"; }
}
And the symptoms, from output/03-mutable-key.txt:
before: get = 100, contains = true

after acc.owner = "ankur":
map.get(acc)             = null
set.contains(acc)        = false
map.containsKey(acc)     = false
map.size()               = 1
iteration still sees it  = [Account(ankur)]
map.remove(acc)          = null
map.size() after remove  = 1

set.add(acc) again -> size = 2 [Account(ankur), Account(ankur)]  (one object, two entries)

immutable key: map.get("asha") = 1
Four things worth noticing: get and containsKey miss; remove misses too, so you cannot even clean up; the map’s size stays 1, so the entry leaks; iterating the key set still shows the object. Re-adding the same object to a set gives two entries for one object. Strings are safe as keys precisely because they are immutable.
Rule of thumb: never mutate a field that feeds hashCode() while the object is inside a hash collection. Better, make key classes immutable, so the question never comes up.

Records generate both methods for you

Since Java 16 a record gets equals(), hashCode() and toString() from its components. Two records with equal components are equal, and the contract rules above all hold, so records are the safest default for value types and map keys. From RecordsDemo.java:
record Book(String isbn, String title) {}
record WithArray(String name, int[] values) {}
record WithList(String name, List<Integer> values) {}
record WithDouble(double d) {}
== generated equals/hashCode ==
a.equals(b)                = true
a.hashCode() == b.hashCode() = true
new HashSet of both, size  = 1
a == b                     = false
The generated version is not magic, though, and it has three edges, all in output/05-records.txt:
== array component: reference equality ==
w1.equals(w2) = false
same array shared: true
== doubles compare with Double.compare semantics ==
NaN record equals NaN record = true
Double.NaN == Double.NaN     = false
0.0 record equals -0.0 record = false
0.0 == -0.0                   = true
An array component is compared by reference, so two records holding equal-looking arrays are not equal. Doubles are compared with Double.compare, per the Record Javadoc, so NaN equals NaN and 0.0 does not equal -0.0, both unlike the == operator. And a record is only as immutable as its components:
== mutable List component: equals works, hashCode can change ==
hashCode before = 4714, after list.add(3) = 34537
record in HashSet, list mutated -> contains = false
Mutate the list after the record is in a set and you have the stranded-entry bug from the previous section, now hiding inside something that looks immutable. Use List.copyOf(values) in a compact constructor (see Immutable Collections in Java) or use a different component type.
Going deeper: overriding equals() in a record, and what the Javadoc promises You may override the generated methods. If you do, keep the pair consistent. The Tagged record below treats the ISBN as its identity:
record Tagged(String isbn, String title) {   // custom equals: identity is the ISBN only
    @Override public boolean equals(Object o) { return o instanceof Tagged t && isbn.equals(t.isbn); }
    @Override public int hashCode() { return isbn.hashCode(); }
}
== overriding equals in a record ==
t1.equals(t2) = true
toString still shows both fields: Tagged[isbn=1, title=Effective Java 3e]
Two records with different titles are equal, and toString() still prints both fields. That is allowed, but it is the one place a reader of the record header will be surprised, so say so in a comment. The Record Javadoc also says the exact algorithm of the generated hashCode() is unspecified and may change, so never persist a record’s hash code or depend on its numeric value.

The JPA entity trap: an id that appears after the object is in a set

Generating equals()/hashCode() from the database id looks natural, and IDE templates offer it. The trouble is that a new entity has no id until it is saved, so its hash code changes at that moment. That is the mutable-key bug again, with the mutation done by the persistence layer. The simulation in EntityIdDemo.java has no database or Hibernate: save() assigns the id, standing in for persist(). It demonstrates the Java mechanism only.
static final class IdEntity {
    Long id; final String name;
    IdEntity(String name) { this.name = name; }
    void save() { if (id == null) id = ++sequence; }   // stands in for persist()
    @Override public boolean equals(Object o) { return o instanceof IdEntity e && Objects.equals(id, e.id); }
    @Override public int hashCode() { return Objects.hashCode(id); }
}
== id-based equals/hashCode, id assigned on save ==
two unsaved entities equal?  true  (both ids null)
set size after adding two different unsaved entities = 1
add, then save(): set.contains(c) = false
Two different unsaved entities are equal (both ids are null), so the set silently drops one. And an entity added before save() cannot be found afterwards, which is the shape of the bug you hit when a new child is added to a Set on its parent and the id is assigned afterwards. When the id is assigned depends on the generation strategy (see Hibernate ID Generation); I did not run Hibernate for this post, so treat the timing as something to check in your own mapping. The version that survives uses a value that is set once and never changes, such as a business key (an email, an ISBN). It is NaturalKeyEntity in the same EntityIdDemo.java, with output in output/06-entity-id.txt:
static final class NaturalKeyEntity {
    Long id; final String email;
    NaturalKeyEntity(String email) { this.email = email; }
    void save() { if (id == null) id = ++sequence; }
    @Override public boolean equals(Object o) { return o instanceof NaturalKeyEntity e && email.equals(e.email); }
    @Override public int hashCode() { return email.hashCode(); }
}
== natural key, immutable ==
add, then save(): set.contains(e) = true
Going deeper: the constant-hash workaround, and when there is no natural key The second common recipe returns a constant hash code and compares ids only when both are set. It keeps the entity findable after save():
== constant hashCode ==
add, then save(): set.contains(d) = true
It works because every entity lives in one bucket, so the hash cannot change. You pay for it exactly as measured in the constant-hash block earlier: every lookup scans the whole bucket, which is fine for the few dozen children of one parent and bad for a large collection. If the entity has no natural key, the options are the constant-hash recipe, a client-generated id (a UUID assigned in the constructor, which is set before the object is ever placed in a set), or keeping unsaved entities out of hash-based collections until they have an id. I did not run these against a real provider; check your mapping and your provider’s documentation before choosing.

Writing hashCode(): what the JDK computes, and what to use

For your own classes, Objects.hash(a, b, c) is the readable default and what the Point example above uses. The JDK’s own types follow published formulas, which is useful to know when you are debugging collisions. From HashCodeRecipeDemo.java, via output/07-hashcode-recipes.txt:
== what the JDK uses ==
"hello".hashCode()            = 99162322
31*h + c over "hello"          = 99162322
List.of(1,2,3).hashCode()      = 30817
Arrays.hashCode({1,2,3})       = 30817
Objects.hash(1,2,3)            = 30817
Set.of(1,2,3).hashCode()       = 6  (sum of elements)
Map.of(1,2).hashCode()         = 3  (key^value per entry, summed)
A string’s hash is 31*h + c over its characters, a list’s hash follows the same shape over its elements (and Objects.hash and Arrays.hashCode produce the same number as List.hashCode for the same elements), and sets and maps sum their elements so that order does not matter. Equal hash codes do not mean equal objects:
== equal hashCodes do not mean equal objects ==
"Aa".hashCode() = 2112, "BB".hashCode() = 2112, equal strings? false
"Aa" and "BB" collide, which is a legitimate, well-known pair. Collisions are normal; equals() is what separates them. Two traps in the recipe, both from the same file:
== Objects.hash(...) traps ==
Objects.hash("a") vs "a".hashCode() = 128 vs 97
Objects.hash(1, 2) == Objects.hash(2, 1) ? false
Objects.hash() (no args)       = 1
Objects.hash((Object) null)    = 31
== Arrays as fields ==
x.equals(y) = false, Arrays.equals = true
x.hashCode() == y.hashCode() ? false
Arrays.hashCode equal ? true
Objects.hash("a") is 128, not "a".hashCode() (97), because it folds the value into a running 31*result + h starting from 1. That matters only if you mix the two. Order matters in Objects.hash (1,2 differs from 2,1). And for an array field you must use Arrays.equals and Arrays.hashCode, since array.equals and array.hashCode() are identity-based.
Going deeper: IDE and Lombok generators, and what Objects.hash costs IDE generators (IntelliJ, Eclipse) and Lombok’s @EqualsAndHashCode produce the same shape as the example above: identity check, type check, field comparison, Objects.hash-style combination. I did not run Lombok for this post, so that is the documented intent rather than something I executed; check what it generates for your fields and for inheritance (it has its own opt-in for callSuper). Objects.hash(...) is a varargs call, so it allocates an array and boxes primitive fields on each call; I did not benchmark it here, and for a hot key class you may prefer to combine fields by hand or cache the result in an immutable object.

Checklist, and when to skip all this

Should you hand-write these at all? Usually no. Use a record for value types and map keys, make the component types immutable, and you get a correct pair with nothing to maintain. Hand-write them only for classes with identity that is not “all fields” (entities, caches keyed by an ID, objects wrapping resources). When you do: compare and hash the same fields; keep those fields immutable or the object out of hash collections while they change; make the class final or document the subclass rule; and write a test that adds the object to a HashSet, mutates nothing, and finds it again, which is the cheapest bug catcher in this whole post.
Interview quick answers Q: What is the contract between equals() and hashCode()? If two objects are equal, their hash codes must be equal. The reverse is not required.

Q: What happens if you override only equals()? Equal objects usually have different hash codes, so hash-based collections treat them as distinct: duplicates in a HashSet and failed HashMap lookups (output/02).

Q: What if you override only hashCode()? Lookups reach the right bucket but then fall back to identity equality, so equal-looking objects are still not found.

Q: Can hashCode() return a constant? Yes, legally, but every key lands in one bucket and lookups degrade (2,002,999 comparisons for 2,000 gets in my run).

Q: Why are String and Integer good map keys? They are immutable, so the hash cannot change under the map (output/03).

Q: What does a record give you? A component-wise equals() and hashCode(), with arrays compared by reference and doubles by Double.compare (output/05). See also the broader Top 50 Java interview questions.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.