Backend / Python core / Tricky questions / 23_empty_class_as_key.md

Empty class as a dict key

Updated 4 interview angles 3 min read source
On this page6
  1. The gotcha
  2. Why it works
  3. What breaks it
  4. The fixes
  5. The deeper rule: mutability
  6. Interview angle

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.

python
class Empty:
    pass

a, b = Empty(), Empty()
{a: 1, b: 2}        # fine, two distinct keys

Why 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

python
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

python
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.

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: int

{Point(1): "a"}[Point(1)]       # "a"

Gotcha: a plain @dataclass generates __eq__ but sets __hash__ = None, exactly like the hand-written case. Only frozen=True (or eq=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:

python
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 key

The 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=True dataclass which generates both from the fields. A plain @dataclass has 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.