The data model — dunder methods
How len, ==, +, sorted and print reach into your class, and the __eq__/__hash__ pairing that makes objects safe to use as keys.
After this lesson you can
- Name the special method behind len(), ==, +, bool() and repr()
- Keep __eq__ and __hash__ consistent, and say what happens when only __eq__ is defined
- Return NotImplemented so Python can try the other operand
Python's operators and built-in functions do not know about your class. They call special methods ("dunder" methods, for the double underscores) that your class may define:
| You write | Python calls |
|---|---|
len(x) | x.__len__() |
a == b | a.__eq__(b) |
a + b | a.__add__(b), then b.__radd__(a) |
a < b, sorted(xs) | a.__lt__(b) |
if x: | x.__bool__(), else x.__len__() |
repr(x), str(x) | x.__repr__(), x.__str__() |
hash(x), x in some_set | x.__hash__() |
Implementing the right ones is what makes an object feel built in.
repr, str, and equality
__repr__ is for developers: unambiguous, ideally the expression that
rebuilds the object. __str__ is for display and falls back to
__repr__ when missing. Containers always show their elements' repr,
which is why a list of objects without one prints memory addresses.
The default __eq__ is identity: two separately built objects are never
equal, whatever they hold. Once you define __eq__, Python sets
__hash__ to None, so the class becomes unhashable — it can no
longer be a dict key or a set member — until you write a matching
__hash__. The contract is one line: objects that compare equal must
have equal hashes. Hash the same fields __eq__ compares, and only
fields that never change after construction.
class Plain: def __init__(self, x): self.x = x class Point: def __init__(self, x, y): self.x, self.y = x, y def __repr__(self): return f"Point({self.x}, {self.y})" def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return (self.x, self.y) == (other.x, other.y) def run(): p, q = Point(1, 2), Point(1, 2) try: hash(p) hashing = "hashable" except TypeError as e: hashing = f"TypeError: {e}" return { "plain_equal": Plain(1) == Plain(1), "point_equal": p == q, "point_is": p is q, "point_vs_tuple": p == (1, 2), "str_of_list": str([p, q]), "hash": hashing, }NotImplemented, and the reflected operand
When __eq__ or __add__ receives a type it does not understand, it
should return NotImplemented (a built-in constant, not an
exception). Python then asks the other operand: b.__radd__(a) for
+, b.__eq__(a) for ==. If both decline, == falls back to
identity and + raises TypeError. Returning False instead would
silently cut that second chance off.
sum() starts from 0, so its first step is 0 + first_item: the
int declines, and only a __radd__ on your class can make it work.
class Money: def __init__(self, cents): self.cents = cents def __repr__(self): return f"Money({self.cents})" def __add__(self, other): if isinstance(other, Money): return Money(self.cents + other.cents) return NotImplemented def __radd__(self, other): if other == 0: # sum() starts from 0 return self return NotImplemented class NoRadd: def __init__(self, cents): self.cents = cents def __add__(self, other): # same __add__, but no __radd__ if isinstance(other, NoRadd): return NoRadd(self.cents + other.cents) return NotImplemented def attempt(fn): try: return repr(fn()) except TypeError as e: return f"TypeError: {e}" def run(): return { "sum_money": attempt(lambda: sum([Money(150), Money(275)])), "sum_without_radd": attempt(lambda: sum([NoRadd(1), NoRadd(2)])), "money_plus_str": attempt(lambda: Money(1) + "x"), }Ordering and truthiness
sorted, min and max only need __lt__. For a > b, Python tries
a.__gt__(b) and, if that is not defined, the reflected b.__lt__(a).
functools.total_ordering fills in the remaining comparisons from
__eq__ plus one of them. An object with __len__ and no __bool__ is
falsy when its length is zero, exactly like an empty list.
class Card: ORDER = "23456789TJQKA" def __init__(self, rank): self.rank = rank def __repr__(self): return f"Card({self.rank!r})" def __lt__(self, other): return Card.ORDER.index(self.rank) < Card.ORDER.index(other.rank) class Inbox: def __init__(self, messages): self.messages = list(messages) def __len__(self): return len(self.messages) def run(): hand = [Card("K"), Card("3"), Card("A"), Card("T")] return { "sorted": repr(sorted(hand)), "max": repr(max(hand)), "ace_gt_king": Card("A") > Card("K"), "empty_is_truthy": bool(Inbox([])), "full_is_truthy": bool(Inbox(["hi"])), }Common mistakes
- Defining
__eq__and forgetting__hash__. Every attempt to put the object in a set or use it as a key now fails withunhashable type. - Hashing a field that
__eq__ignores, or a field that changes later. Equal objects then get different hashes, so a set keeps both, and an object whose hash changed after insertion can no longer be found. - Returning
Falsefrom__eq__or raisingTypeErrorfrom__add__for an unknown type, instead of returningNotImplemented. - Writing only
__str__. Logs, debuggers and containers userepr.
What interviewers ask
- What is the relationship between
__eq__and__hash__? Equal objects must hash equal; defining__eq__alone makes the class unhashable, and a mutable object should usually stay that way. __repr__against__str__? Unambiguous for developers against readable for users;strfalls back to , not the other way.
Exercise · Pro
An interval type that works with len, in, & and sets
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade