The equals(), hashCode(), and toString() Contracts
Objective
Object.equals(), Object.hashCode(), and Object.toString() each come with a documented contract, not just a default implementation. Overriding one without honoring its contract — or overriding equals() while leaving hashCode() alone — compiles cleanly and fails silently at runtime: broken collection lookups, objects that stop being equal to themselves under composition, unreadable log output. This concept covers what each contract actually requires and how to satisfy all three correctly.
Use Cases
- Giving a class logical equality instead of the identity equality
Objectprovides by default — two separate instances that represent the same value (a point, a money amount, an ID) should compare equal. - Making a class safe to use as a
HashMapkey orHashSetelement, where correctness depends onequals()andhashCode()agreeing with each other. - Producing a
toString()that turns a log line or a debugger watch expression fromOrder@1a2b3cinto something a person can actually read. - Recognizing when not to override
equals()at all — classes with inherent identity (likeThread), or ones where a superclass'sequals()is already correct.
Deep Dive
The equals() contract, and how symmetry breaks first
Object.equals(Object) is documented as an equivalence relation. For any non-null references x, y, z:
- Reflexive —
x.equals(x)must betrue. - Symmetric —
x.equals(y)must betrueif and only ify.equals(x)istrue. - Transitive — if
x.equals(y)andy.equals(z)are bothtrue, thenx.equals(z)must betrue. - Consistent — repeated calls to
x.equals(y)return the same result, as long as neither object's compared state changes. - Non-null —
x.equals(null)must befalse.
The requirement that breaks first in practice is symmetry, and the classic trigger is a subclass that adds a field the superclass's equals() doesn't know about:
javapublic class Point {
private final int x, y;
public 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 p.x == x && p.y == y;
}
}
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
// Broken — violates symmetry
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint cp)) return false;
return super.equals(o) && cp.color == color;
}
}javaPoint p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
p.equals(cp); // true — Point.equals only looks at x and y
cp.equals(p); // false — ColorPoint.equals requires a ColorPointTrying to "fix" this by having ColorPoint.equals() fall back to a color-blind comparison when the argument is a plain Point restores symmetry but breaks transitivity instead — two ColorPoints of different colors can each equal the same Point at (1, 2) without equaling each other. There's no way to add a value component in a subclass and preserve the full contract while still extending a concrete, instantiable class. The two ways out are: don't add a value component in the subclass, or don't extend — give ColorPoint a Point field (composition) instead of a Point superclass.
A correct equals() follows the same shape regardless: compare by reference first (this == o) as a cheap short-circuit, then use instanceof — which returns false for null and for the wrong type in one check, covering both the non-null requirement and the type check without a separate null guard:
java@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return p.x == x && p.y == y;
}For fields that can themselves be null, Objects.equals(a, b) does the null-safe comparison (true if both are null, otherwise a.equals(b)) instead of hand-rolling it per field.
equals() and hashCode() are one contract, not two
Object.hashCode() is documented with its own rules, but the one that matters here is: if two objects are equal according to equals(), they must return the same hashCode(). Nothing in the language enforces this — a class can override equals() and leave hashCode() untouched, and it will compile without warning. What breaks is every hash-based collection built on the assumption that the two methods agree.
javapublic final class Point {
private final int x, y;
public 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 p.x == x && p.y == y;
}
// no hashCode() override — inherits Object's identity-based hash
}javaMap<Point, String> labels = new HashMap<>();
labels.put(new Point(1, 2), "origin-ish");
labels.get(new Point(1, 2)); // null — not "origin-ish"Both Point instances are equal by equals(), but each carries its own identity-derived hashCode(), so put() and get() almost certainly land in different buckets — and even in the rare case they collide, HashMap caches each entry's hash and skips the equals() check entirely when the hashes don't match. The lookup fails without throwing anything; it just silently doesn't find what's obviously "there."
A correct hashCode() follows a simple recipe: start from a nonzero constant, and for every field also used in equals(), fold it in with result = 31 * result + fieldHash:
java@Override
public int hashCode() {
int result = 17;
result = 31 * result + x;
result = 31 * result + y;
return result;
}java.util.Objects provides a shortcut that does the same folding for any number of fields, at a small varargs/boxing cost:
java@Override
public int hashCode() {
return Objects.hash(x, y);
}The rule that actually matters for correctness is narrower than "hash every field": every field read by equals() must be read by hashCode() too. Leaving one out risks equal objects hashing differently; including a field equals() ignores just adds noise. Whichever fields go in, hashCode() must derive them the same way every time the object is unchanged — see Consistency in the equals() contract above — otherwise a mutable field used in the hash breaks lookups the moment it's mutated after insertion (see the hash-bucket mechanics in the HashMap concept for what that looks like on the bucket side).
toString(): what Object gives you, and why it's not enough
Object.toString()'s default implementation returns the class name, @, and the hex hash code — Point@7229724f. It's not wrong, just useless: it tells you nothing about which point this is. toString() is invoked automatically by println, string concatenation, String.format/printf, assert messages, and most debuggers — so a class that never overrides it produces unreadable output in every one of those places without anyone having to ask for it:
javaSystem.out.println("Failed to connect: " + phoneNumber);
// no override: Failed to connect: PhoneNumber@1a2b3c
// overridden: Failed to connect: (707) 867-5309Overriding it is a two-part decision: what to put in the string, and whether to document its format as part of the class's API. Documenting an exact format (as BigInteger and BigDecimal do) gives callers a stable, parseable representation — and it's worth pairing with a static factory or constructor that parses it back. Leaving the format unspecified keeps the freedom to change it later, at the cost of callers having no textual contract to rely on. Either way, the class should still expose the underlying data through real accessors — a toString() output is a poor substitute for an API, and parsing it back defeats the point of writing structured code in the first place.
java/**
* Returns this point's coordinates, formatted as "(x, y)".
*/
@Override
public String toString() {
return "(" + x + ", " + y + ")";
}Records: the same three methods, generated correctly
A record generates equals(), hashCode(), and toString() for every component automatically, and generates them consistently with each other by construction — there's no way to end up with a record whose equals() and hashCode() disagree, because both are derived from the same component list by the compiler, not written by hand twice:
javarecord Point(int x, int y) {}
Point a = new Point(1, 2);
Point b = new Point(1, 2);
a.equals(b); // true — structural equality over every component
a.hashCode() == b.hashCode(); // true — always, for equal records
a.toString(); // "Point[x=1, y=2]"For a plain value type, this replaces the entire recipe above with zero hand-written code. It doesn't replace judgment, though — the ColorPoint-style symmetry problem still applies if a record is compared against a differently-shaped type, and the "own reference vs. mutable content" caveat around record fields still applies to whatever's inside. General record mechanics (compact constructors, sealed hierarchies with records) are covered in the Records and Sealed Types concept; this is just the equals/hashCode/toString angle.
Trade-offs
instanceofvs.getClass()inequals()trades symmetry for substitutability. Aninstanceofcheck lets a subclass compare equal to its superclass (as long as the subclass adds no value component), which is what thePoint/ColorPointexample above needed to avoid. AgetClass()check sidesteps that specific symmetry problem by requiring an exact type match — but it then breaks the Liskov substitution principle for harmless subclasses that add no state at all (e.g. one that just counts how many instances were created), because such a subclass instance can never equal an otherwise-identical superclass instance.java// getClass()-based equals: exact type match only @Override public boolean equals(Object o) { if (o == null || o.getClass() != getClass()) return false; Point p = (Point) o; return p.x == x && p.y == y; } // a Set<Point> built with new Point(...) instances will never // report true for contains() on an equal CounterPoint instance, // even though CounterPoint adds no comparable state- Overriding
equals()withouthashCode()compiles without error or warning. Nothing in the type system links the two methods, so the mistake only shows up as a collection silently misbehaving at runtime, not as a build failure.javaSet<Point> seen = new HashSet<>(); seen.add(new Point(1, 2)); seen.add(new Point(1, 2)); // equal by equals(), but no hashCode() override seen.size(); // 2, not 1 — the "duplicate" landed in a different bucket - A
hashCode()that ignores the contract's third clause is still legal, just bad. Returning a constant satisfies "equal objects have equal hash codes" — trivially, since every object has the same hash — but it collapses every bucket into one, turning average O(1) hash-table operations into O(n).java@Override public int hashCode() { return 42; } // legal, atrocious - Specifying
toString()'s exact format is a one-way door. It gives callers something stable to parse and log against, but once published and depended on, changing it is a breaking change — the same way changing a public method signature would be. Leaving the format unspecified keeps that freedom but means anyone who parses the output anyway is relying on an explicitly undocumented detail.