__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
d[key]Three steps, in this order:
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 repeatTwo 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
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:
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")}) # 2The 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:
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:
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)} - deduplicateddataclasses
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:
@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:
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 vanishedTwo 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 -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
@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.
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.
Related
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.
- 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
- “You defined
__eq__and the class became unhashable. Why?” — Python sets__hash__ = Nonedeliberately. 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 seenreturns 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 haveid=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. Usehashliborcrc32for anything persisted.- Follow-up: “Why does that bug survive testing?” — integers are not
randomised, so a test using
hash(42)passes and production usinghash(user_key)does not.
- Follow-up: “Why does that bug survive testing?” — integers are not
randomised, so a test using
- “Does
@dataclass(frozen=True)guarantee hashability?” — no. It prevents rebinding attributes, not mutation of their values, so alistfield still raises athash()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.