backend / python core / 23_eq_vs_hash.md

__eq__ vs __hash__

5 min read source

__eq__ vs __hash__

What __eq__ is

__eq__(self, other) defines the == operator. When you write a == b, Python calls a.__eq__(b); if that returns NotImplemented, it tries b.__eq__(a). The default (object.__eq__) compares identity — same object in memory — which is why two freshly-built User(1) instances are not equal by default.

class A: pass
A() == A()        # False — default __eq__ is identity
[1, 2] == [1, 2]  # True  — list.__eq__ compares element-wise

!= derives from __eq__ automatically (negation), so you only override __eq__.

What __hash__ is

__hash__(self) returns an integer used by dict, set, and frozenset to bucket the object. Hashing is what makes lookup O(1) average — Python computes hash(key), jumps to the bucket, then uses __eq__ to confirm a match.

hash("abc")        # some int, stable within a process
hash([1, 2])       # TypeError — lists are mutable, not hashable
{1, 2, 3}          # set uses hash() of each element
{"a": 1}["a"]      # dict computes hash("a") to find the bucket

The default (object.__hash__) returns a value derived from id(self) — unique per instance, consistent with the default identity-based __eq__.

The contract

The two methods have a contract you can’t break: if a == b, then hash(a) == hash(b). Violate it and dicts/sets corrupt silently.

a == b   →   hash(a) == hash(b)

The reverse is not required. Two unequal objects may share a hash (a hash collision); the dict/set then falls back to == to disambiguate. But equal objects with different hashes break lookup — they land in different buckets and the second insert silently shadows the first.

What Python does by default

  • object.__eq__ is identity (a is b).
  • object.__hash__ is id(a) (well, derived from it).
  • Both honor the contract: a is b implies id(a) == id(b).

So the default is safe but coarse — you can’t put two different User instances representing the same DB row into a set and have it dedupe.

Override __eq__ and __hash__ is killed

If you only override __eq__, Python sets __hash__ = None automatically. The class becomes unhashable.

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

u = User(1)
hash(u)         # TypeError: unhashable type: 'User'
{u}             # TypeError

This is a feature, not a bug. Python forces you to think: if I’m changing equality, what should the hash be?

The fix — define both

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)

assert User(1) == User(1)
assert hash(User(1)) == hash(User(1))
{User(1), User(1)}        # {User(1)} — deduplicated

Hash whatever participates in equality — same fields, same order.

Mutable objects shouldn’t be hashable

If __hash__ depends on mutable state, mutating after insertion breaks the dict.

class Bag:
    def __init__(self):
        self.items = []
    def __eq__(self, other):
        return isinstance(other, Bag) and self.items == other.items
    def __hash__(self):
        return hash(tuple(self.items))   # depends on mutable list

b = Bag()
s = {b}
b.items.append(1)         # mutated
b in s                    # False — hash changed, can't find it

The dict located b at its old hash bucket; lookup now computes the new hash and looks in the wrong bucket. The object is “lost” inside the set.

Rule: hashable objects should be effectively immutable. Either don’t make mutable types hashable (__hash__ = None), or hash only fields you guarantee not to mutate (often: a stable ID).

dataclasses and the eq/hash interaction

@dataclass generates __eq__ by default, which kills __hash__. To opt in:

from dataclasses import dataclass

@dataclass(frozen=True)             # immutable + hashable
class Point:
    x: int
    y: int

@dataclass(eq=True, unsafe_hash=True)  # mutable + hashable (you take responsibility)
class Mutable:
    x: int

@dataclass(eq=False)                 # no __eq__ override → default identity hash
class Identity:
    x: int
@dataclass flags __eq__ __hash__
default generated by-fields None (unhashable)
frozen=True generated by-fields generated by-fields
eq=False default (identity) default (id-based)
unsafe_hash=True generated by-fields generated by-fields, mutable allowed

frozen=True is the right choice almost always. unsafe_hash=True is for cases where you’ve guaranteed (somehow) the hashed fields don’t change.

Why @dataclass(eq=True) (the default) kills __hash__

The dataclass decorator follows the same rule the language enforces: if you change equality, hashability is your responsibility to re-establish. Defaults reasoning:

  1. @dataclass generates __eq__ that compares all fields.
  2. Instances are mutable (you can assign to obj.x = ... after creation).
  3. If __hash__ were also generated by-fields, mutating any field would change the object’s hash mid-life — violating “hash must not change for the lifetime of the object.”

So Python sets __hash__ = None. You then have three explicit choices:

  • frozen=True → instances become immutable; safe to hash by fields.
  • unsafe_hash=True → “I promise these fields won’t change after I put this in a dict.” Caller’s responsibility.
  • eq=False → fall back to identity-based __eq__ and __hash__ (default object behavior); two equal-by-fields instances are no longer equal.

Same mechanism, no magic — @dataclass is just enforcing the contract one decorator-deep.

Inheritance and hash

If a subclass overrides __eq__, Python sets __hash__ to None on the subclass — even if the parent had a working __hash__.

class A:
    def __hash__(self):
        return 42
    def __eq__(self, other):
        return True

class B(A):
    def __eq__(self, other):
        return isinstance(other, B)

hash(A())     # 42
hash(B())     # TypeError

To inherit the parent’s hash explicitly:

class B(A):
    def __eq__(self, other): ...
    __hash__ = A.__hash__

Common patterns

ID-based equality (entity in a domain model):

class Order:
    def __init__(self, id, items):
        self.id = id
        self.items = items
    def __eq__(self, other):
        return isinstance(other, Order) and self.id == other.id
    def __hash__(self):
        return hash(self.id)

Two Order objects represent the same order if they have the same ID, regardless of items. Items can change without breaking dict lookups.

Value-based equality (value object):

@dataclass(frozen=True)
class Money:
    amount: int
    currency: str

Two Money instances are equal iff all fields match. Frozen → safe to use as dict key.

Disable hashing on a mutable equality:

class Cart:
    def __eq__(self, other):
        return isinstance(other, Cart) and self.items == other.items
    __hash__ = None        # explicitly unhashable; can't end up as a dict key by accident

What to put in hash

def __hash__(self):
    return hash((self.id, self.kind))   # tuple of fields

hash(tuple_of_fields) is the canonical idiom — Python’s tuple hashing combines the field hashes well. Avoid arithmetic combinations like self.x ^ self.y — collisions are easier to construct, performance is no better.

Interview angle

  • Q: “What’s the contract between __eq__ and __hash__?” — a == b implies hash(a) == hash(b).
  • Q: “What happens if you override __eq__ and not __hash__?” — Python sets __hash__ = None; class becomes unhashable.
  • Follow-up: “Why shouldn’t mutable objects be hashable?” — hash depends on state; mutation after insertion breaks lookup.
  • Follow-up: “How do dataclasses handle this?” — @dataclass(frozen=True) for value objects (immutable + hashable); default kills __hash__ because it generates __eq__.

See 18_dict_hashable_objects.md for hashable types in general, tricky_questions/03_is_vs_equals_interning.md for is vs == semantics, tricky_questions/23_empty_class_as_key.md for the default-hash gotcha.