Cloud Tech by Victor
AlgorithmsIntermediate

Hash Tables & the Hash/Equality Contract

Why two objects that compare equal but hash differently don't raise an error when used as a dict key, they just silently fail to find each other, and why that makes the __eq__/__hash__ contract one of the most dangerous ones to get wrong.

Updated 2026-07-244 min read

Overview

A hash table's lookup speed comes from using an object's hash value to jump directly to the right bucket, then using equality only to confirm the exact match within that bucket, which is why the required contract is strict and one-directional: any two objects that compare equal must produce the same hash, or a lookup for one will search an entirely different bucket than where an "equal" object actually lives and simply never find it. In Python 3, defining a custom __eq__ without also defining __hash__ doesn't quietly leave the old identity-based hash in place, Python sets __hash__ to None on that class, making instances unhashable, so using one as a dict key raises TypeError rather than silently corrupting anything. The genuinely silent version of this bug only shows up when an incompatible hash is explicitly kept or supplied anyway (an inherited or manually reassigned identity-based __hash__ alongside a value-based __eq__), in that case a == b can be true while d.get(b) returns nothing for a dict keyed by a, with nothing raised to point at the cause. This is also exactly why Python's own guidance says a class representing mutable objects with a custom __eq__ shouldn't define __hash__ at all, a hash computed from fields that can later change would strand the object in the wrong bucket the moment it mutates.

Quick Reference

RuleWhy
a == b implies hash(a) == hash(b)Lookup uses hash to pick the bucket, equality only confirms within it
Unequal objects can share a hashCollisions are expected and handled; only equal objects sharing hashes is required
Mutable object + custom __eq__Should not define __hash__; mutation would strand it in the wrong bucket

Syntax

python
from dataclasses import dataclass

@dataclass(frozen=True)  # immutable - x and y can't change after construction
class Point:
    x: int
    y: int

    def __hash__(self):
        return hash((self.x, self.y))  # same fields used in __eq__

Examples

python
class Bad:
    def __init__(self, v): self.v = v
    def __eq__(self, other): return self.v == other.v
    # Defining __eq__ without __hash__ sets __hash__ to None;
    # Python makes the class unhashable rather than risk a silent
    # contract violation with the old identity-based hash.

a, b = Bad(42), Bad(42)
a == b       # True
hash(a)      # TypeError: unhashable type: 'Bad'

d = {a: "found"}  # also raises TypeError: unhashable type: 'Bad'

Silent lookup failures require an explicitly retained incompatible hash - the default case raises TypeError

Python 3 protects against the common case automatically: defining __eq__ without __hash__ makes the class unhashable, so hash() raises TypeError right away instead of corrupting lookups. The silent failure mode only appears if an incompatible hash is explicitly kept or supplied anyway; then dictionary and set lookups just quietly return the wrong (or no) result, one of the harder classes of bug to catch precisely because nothing raises to point at the cause.

Visual Diagram

Common Mistakes

  • Overriding __eq__ based on an object's value while leaving __hash__ at its identity-based default, breaking the contract silently.
  • Making a mutable object hashable based on fields that can change after insertion, stranding it in the wrong bucket once mutated.
  • Assuming a failed dictionary lookup for an object that "looks equal" to a stored key must be an equality bug, when it's often actually a hash mismatch.
  • Treating hash collisions between unequal objects as a bug; they're expected and handled by the hash table, only equal-but-differently-hashed objects are the actual problem.

Performance

  • A hash table's average-case O(1) lookup depends on hashes being well-distributed; a poor hash function that clusters many unequal objects into few buckets degrades toward O(n) lookups within those buckets.
  • Computing a hash from the same fields used in equality (rather than, say, a cheaper subset) keeps the contract correct at a typically negligible added computation cost.

Best Practices

  • Always derive __hash__ from exactly the same fields used in __eq__, so the required contract holds by construction.
  • Don't define __hash__ at all for a class representing mutable objects with a custom __eq__; let it stay unhashable rather than risk silent corruption.
  • If a lookup for an "equal" key mysteriously fails, check the hash/equality contract first, it's a common, hard-to-spot root cause.
  • Prefer immutable, value-based key types (tuples, frozen dataclasses) for dictionary keys over mutable custom objects when possible.

Interview questions

What is the required contract between equality and hashing for an object used as a dictionary key, and why does the hash table need it specifically?

The contract is one-directional but strict: if two objects compare equal, they must produce the same hash value. A hash table uses an object's hash to pick which bucket to look in, then uses equality only to confirm the exact match within that bucket, so if two equal objects hashed differently, a lookup for one would search the wrong bucket entirely and never even reach the equality check that would have confirmed the match. The hash doesn't have to be unique across unequal objects (collisions are expected and handled), it just has to agree for anything that compares equal, that's the one property the whole lookup mechanism depends on.

A custom class defines __eq__ based on a value field but leaves __hash__ using default identity-based hashing. What actually goes wrong when you use an instance as a dict key?

In Python 3, simply defining `__eq__` without touching `__hash__` doesn't leave the old identity-based hash in place, Python automatically sets `__hash__` to `None` on that class, making instances unhashable, so using one as a dict key raises `TypeError` immediately rather than corrupting anything. This happens even if a parent class defines `__hash__`: overriding `__eq__` in a subclass sets that subclass's `__hash__` to `None` regardless of what the parent provides, ordinary inheritance does not carry the parent's hash forward. The silent, no-exception version of this bug only happens if the class explicitly keeps or re-supplies an identity-based `__hash__` alongside the value-based `__eq__` (e.g. `__hash__ = object.__hash__`, or, to retain a parent's hash on purpose, `__hash__ = Parent.__hash__`). In that case, two instances with the same value compare equal (`a == b` is `True`) but hash differently, and inserting under key `a` then looking up with an equal-but-distinct key `b` lands in the wrong hash bucket and returns nothing, because the hash mismatch never gave the lookup a chance to even check equality against the right entry.

Why does Python's documentation say a class defining mutable objects with a custom __eq__ should not implement __hash__ at all?

If an object's hash is derived from fields that can change after the object is already stored as a dict key, mutating it changes its hash value, but the object stays in whatever bucket it was originally placed in based on the old hash, so a subsequent lookup computes the new hash, looks in the new (wrong) bucket, and fails to find an object that is, in fact, still in the dictionary. Making a mutable object unhashable by default (which not defining `__hash__` effectively signals) prevents this specific class of bug entirely, at the cost of not being able to use that object as a dict key or set member at all, a deliberate, documented trade-off favoring correctness over convenience.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement