Empty class as a dict key
The gotcha
An instance of a class with no methods at all works fine as a dictionary key.
Add one method — __eq__ — and it stops working entirely.
class Empty:
pass
a, b = Empty(), Empty()
{a: 1, b: 2} # fine, two distinct keysWhy it works
Every object inherits two things from object:
__hash__derived from the object’s identity__eq__that is identity comparison
Both requirements of the hash contract are satisfied trivially, because two
distinct instances are never equal and each has its own hash. hash(a) is
derived from id(a), which is why the value changes between runs.
So a and b above are two different keys even though they are
indistinguishable in every other way.
What breaks it
class Point:
def __init__(self, x):
self.x = x
def __eq__(self, other):
return isinstance(other, Point) and self.x == other.x
# TypeError: unhashable type: 'Point'
{Point(1): "a"}Defining __eq__ sets __hash__ = None. Python does this on purpose.
The hash contract says equal objects must have equal hashes. You have just
declared that Point(1) == Point(1), while the inherited hash is still
identity-based and would give them different hashes. Those two facts cannot
both hold, so rather than let you build a dict that silently loses keys,
Python removes hashability.
Check it directly — Point.__hash__ is None, not missing.
The fixes
class Point:
def __init__(self, x):
self.x = x
def __eq__(self, other):
return isinstance(other, Point) and self.x == other.x
def __hash__(self):
return hash(self.x) # same fields as __eq__The rule: hash the same fields you compare. Hashing a field __eq__
ignores puts equal objects in different buckets, and the dict stops finding
them.
Better, if the object is a value: let a frozen dataclass generate both.
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
{Point(1): "a"}[Point(1)] # "a"Gotcha: a plain
@dataclassgenerates__eq__but sets__hash__ = None, exactly like the hand-written case. Onlyfrozen=True(oreq=False) gives you a usable key.
The deeper rule: mutability
The reason frozen=True is required is that a key’s hash must not change
while it is in the dict:
class Bad:
def __init__(self, x): self.x = x
def __hash__(self): return hash(self.x)
def __eq__(self, o): return self.x == o.x
k = Bad(1)
d = {k: "v"}
k.x = 2 # hash changed
d[Bad(2)] # KeyError — wrong bucket
d[k] # KeyError — even the original keyThe entry is still in the dict and is now unreachable by any key. This is why
list and dict are unhashable, and why the identity-based default is the
safe behaviour for a mutable object.
Interview angle 4
- “Can an instance of a plain class be a dict key?” - yes. The default
__hash__is based on identity and the default__eq__is identity comparison, so every instance is distinct and hashable. - “What breaks that?” - defining
__eq__without__hash__, which sets__hash__to None and makes instances unhashable. Python does this deliberately: value equality with identity hashing would violate the contract. - “What’s the fix?” - define both consistently, or use a
frozen=Truedataclass which generates both from the fields. A plain@dataclasshas the same problem as the hand-written class. - “Why must the key be immutable?” - the hash is computed once at insertion to pick a bucket. Mutate a field the hash depends on and the entry becomes unreachable — including by the very object you inserted.