Backend / Python core / 20_eq_vs_hash.md

__eq__ and __hash__

Updated 7 interview angles 7 min read source
On this page7
  1. The mechanism: how a lookup finds a key
  2. Overriding __eq__ kills __hash__
  3. dataclasses
  4. Where it breaks in production
  5. What to put in __hash__
  6. Related
  7. Interview angle

__eq__ and __hash__

Article.

Two methods with a contract between them, and the contract is not a convention — it is what makes dict and set work at all. Break it and nothing raises. You get a dictionary that quietly cannot find a key it is holding.

Everything worth knowing here falls out of one thing: what a dict lookup actually does.

The mechanism: how a lookup finds a key

python
d[key]

Three steps, in this order:

text
1.  h = hash(key)
    index = h & (table_size - 1)   # bucket to start at

2.  slot empty?                         -> KeyError, stop
    stored_key IS key?                  -> hit, by identity
    hash matches AND stored_key == key? -> hit, by equality

3.  otherwise probe the next slot and repeat

Two details in there explain the rest of this note.

The hash decides where to look. It is not a comparison — it is an address. If two equal objects hash differently they start at different buckets, so the lookup never reaches the one you stored and never gets as far as calling ==.

Identity is checked before equality. stored_key is key short-circuits, both as an optimisation and because it makes containers behave sensibly for objects whose __eq__ is unusual.

The contract

text
a == b   implies   hash(a) == hash(b)

The reverse is not required. Two unequal objects may share a hash — that is a collision, and step 3 resolves it by probing and comparing. Collisions cost time; unequal hashes for equal objects cost correctness.

Watch identity-first in action

NaN is not equal to itself, which makes it a clean demonstration:

python
nan = float("nan")
nan == nan            # False

d = {nan: "value"}
d[nan]                # 'value'  <- found by IDENTITY, not equality
d[float("nan")]       # KeyError <- a different object, and == is False
len({nan, nan})       # 1        <- same object
len({float("nan"), float("nan")})   # 2

The first lookup succeeds even though the key is not equal to itself, because step 2 checked is first. Nothing is broken — this is the mechanism being visible.

Overriding __eq__ kills __hash__

Define __eq__ and Python sets __hash__ = None:

python
class User:
    def __init__(self, id): self.id = id
    def __eq__(self, other):
        return isinstance(other, User) and self.id == other.id

hash(User(1))    # TypeError: unhashable type: 'User'

That is a feature. You changed what equality means, so the old identity-based hash would now violate the contract — two equal User(1) instances would hash differently. Rather than let you corrupt a dict silently, Python makes the class unhashable until you decide.

The fix is to hash exactly what equality compares:

python
class User:
    def __init__(self, id): self.id = id
    def __eq__(self, other):
        return isinstance(other, User) and self.id == other.id
    def __hash__(self):
        return hash(self.id)         # same field, same contract

{User(1), User(1)}                   # {User(1)} - deduplicated

dataclasses

The generated behaviour is a small matrix worth knowing outright:

Declaration __eq__ __hash__
@dataclass generated None - unhashable
@dataclass(eq=False) inherited inherited (identity)
@dataclass(frozen=True) generated generated
@dataclass(unsafe_hash=True) generated generated

The default is unhashable, for exactly the reason above. frozen=True gives you a hash because immutability makes it safe. unsafe_hash=True is named honestly: it generates one for a mutable class and hands you the consequences.

Where it breaks in production

The mutated key

The classic, and it is silent:

python
@dataclass(unsafe_hash=True)
class Tag:
    name: str

t = Tag("draft")
seen = {t}

t.name = "published"     # hash changes; the set still holds it at the old bucket

t in seen                # False  <- the object is IN the set
[x for x in seen]        # [Tag(name='published')]

The object is in the set and the set cannot find it. Any hashed container is built on the assumption that a key’s hash never changes while it is stored, and nothing enforces it.

Hash only immutable state. If the fields that define equality can change, the object should not be hashable.

ORM entities before they are saved

The version of the same bug that reaches production most often:

python
class Order(Base):
    id = Column(Integer, primary_key=True)
    def __eq__(self, other): return self.id == other.id
    def __hash__(self):      return hash(self.id)

a, b = Order(), Order()          # both id=None until flush
{a, b}                           # {Order()}  <- one of them vanished

Two unsaved entities both hash None and compare equal, so a set of new objects collapses to one and you lose data before it ever reaches the database. Then the id is assigned at flush and the hash of anything already stored changes.

The safe rule for ORM entities: leave __eq__ and __hash__ alone so they use identity, which is what the session’s identity map relies on anyway. If you need value equality, put it on a separate value object rather than the mapped class.

A hash you persisted

python
$ python -c "print(hash('user:42'))"
-4102954089...
$ python -c "print(hash('user:42'))"
 8814413291...

String and bytes hashing is randomised per process — a security measure against algorithmic-complexity attacks, on by default. So a Python hash() is stable within one process and meaningless across runs.

Which means it must never be used for anything that outlives the process: sharding keys across nodes, a cache key stored in Redis, a partition assignment, a checksum in a file. Use hashlib.sha256 or zlib.crc32 for those — stable everywhere, forever.

(Integers are not randomised, which makes this bug even easier to miss: it works in the test with hash(42) and fails in production with hash(user_id_str).)

Frozen does not mean hashable

python
@dataclass(frozen=True)
class Query:
    table: str
    filters: list[str]        # a list, inside a frozen dataclass

hash(Query("users", []))      # TypeError: unhashable type: 'list'

frozen=True prevents rebinding the attributes; it does not make their values immutable, and the generated __hash__ hashes a tuple of the fields. Use tuple[str, ...] or frozenset for collection fields on anything hashable.

What to put in __hash__

  • The same fields as __eq__, and nothing else.
  • A tuple, which handles combining for you: hash((self.a, self.b)).
  • Only immutable state. If it can change, it does not belong in either.
  • A subset is legal. Hashing on fewer fields than equality compares is correct — it only causes collisions, which are handled. The reverse is not.
python
def __hash__(self):
    return hash((self.tenant_id, self.email))

The trade not taken

The most common right answer is do not define either one.

Default identity semantics are correct for anything with a lifecycle — services, connections, ORM entities, anything mutable. Custom equality is for value objects: things defined entirely by their contents, that never change after construction. Money, a coordinate, a date range, a parsed identifier.

And for those, @dataclass(frozen=True) gives you both methods, correctly, with the immutability that makes them safe — which is why hand-writing this pair is rarer than the amount of interview attention it gets suggests.

Interview angle 7

  • “What is the contract between __eq__ and __hash__?” — if two objects are equal they must hash equally. The reverse is not required; unequal objects sharing a hash is just a collision, resolved by comparison.
    • Follow-up: “Why does breaking it corrupt a dict?” — the hash is an address, not a comparison. Equal objects with different hashes start at different buckets, so the lookup never reaches the stored entry and never gets as far as calling ==. It fails silently.
  • “You defined __eq__ and the class became unhashable. Why?” — Python sets __hash__ = None deliberately. You changed what equality means, so the inherited identity hash would now violate the contract. It refuses rather than letting you corrupt a container.
  • “What happens if you mutate an object after putting it in a set?” — its hash changes, the set still has it at the old bucket, and x in seen returns False for an object the set demonstrably contains. Hash only immutable state.
  • “Should ORM entities define __eq__ and __hash__?” — generally no. Two unsaved instances both have id=None, so they compare equal and collapse in a set, and the hash changes when the id is assigned at flush. Identity semantics are what the session’s identity map expects anyway.
  • “Can you use hash() for sharding?” — no. String and bytes hashing is randomised per process as a defence against collision attacks, so the value is meaningless across runs. Use hashlib or crc32 for anything persisted.
    • Follow-up: “Why does that bug survive testing?” — integers are not randomised, so a test using hash(42) passes and production using hash(user_key) does not.
  • “Does @dataclass(frozen=True) guarantee hashability?” — no. It prevents rebinding attributes, not mutation of their values, so a list field still raises at hash() time. Use tuples or frozensets for collection fields.
  • “When would you not define either?” — almost always. Identity is correct for anything with a lifecycle. Custom equality belongs to value objects, and for those a frozen dataclass generates both correctly.