What you'll learn
- Understand that operators and built-ins call dunder methods, so your classes can join in
- Give objects a readable
__repr__and a friendly__str__ - Make objects comparable and hashable with
__eq__,__lt__, and__hash__ - Support
len(), indexing,in, and arithmetic with the container and operator dunders
Concept
Every operator and every built-in in Python is a method call underneath. a + b is a.__add__(b). len(x) is x.__len__(). x[i] is x.__getitem__(i). print(obj) calls obj.__str__(), and obj == other calls obj.__eq__(other). These double-underscore methods, the data model, are the contract between your classes and the language. Implement the right ones and your objects sort, print, compare, iterate, and add like the built-ins do. You have already met __init__, __repr__, __iter__, __enter__, and __exit__.

Two methods control how an object appears. __repr__ is for developers: unambiguous, ideally the expression that recreates the object, shown in the REPL, in lists, and in tracebacks. __str__ is for users: friendly text, used by print and f-strings. If you define only __repr__, print falls back to it, so a class with a good __repr__ is never embarrassing. Define __str__ only when the user-facing form should differ.
| You want | Implement | Notes |
|---|---|---|
| readable in REPL and logs | __repr__ |
make it look like the constructor call |
friendly print |
__str__ |
optional; falls back to __repr__ |
== by value |
__eq__ |
return NotImplemented for foreign types |
| use as a dictionary key / in a set | __hash__ |
must agree with __eq__; only for immutable objects |
<, sorting, min, max |
__lt__ (+ functools.total_ordering) |
total_ordering fills in the rest |
len(x), x[i], in |
__len__, __getitem__, __contains__ |
__getitem__ alone also enables iteration |
+, *, - |
__add__, __mul__, __sub__ (+ __radd__ etc.) |
return a new object; NotImplemented if unsupported |
bool(x), if x: |
__bool__ (or __len__) |
default is always truthy |
Ordering needs __lt__ at minimum, and functools.total_ordering derives the other comparisons from __lt__ and __eq__. For containers, __len__ and __getitem__ are enough for len(), indexing, slicing if you handle slices, in, and even iteration, because Python falls back to indexing from zero until IndexError. Arithmetic dunders return new objects and never mutate; the reflected forms like __radd__ handle 3 + obj when the left operand does not know your type, and returning NotImplemented from an unsupported combination gives a clean TypeError instead of a wrong answer.
Equality and hashing travel together. By default two instances are equal only if they are the same object. Define __eq__ to compare by value, and return NotImplemented when the other object is a type you do not understand, so Python can try the reverse comparison. Once you define __eq__, Python removes the default __hash__, because equal objects must hash equally; define __hash__ too, built from the same fields, if the object is immutable and you want it usable as a dictionary key or set member. Mutable objects should stay unhashable.

Code
__repr__ versus __str__, and what each context uses. The list shows reprs; print and the f-string show the friendly form.
# repr_str.py
class Money:
def __init__(self, amount, currency):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __str__(self):
return f"{self.amount:.2f} {self.currency}"
price = Money(12.5, "EUR")
print(price)
print(f"total: {price}")
print(repr(price))
print([price, Money(3, "USD")])
12.50 EUR
total: 12.50 EUR
Money(12.5, 'EUR')
[Money(12.5, 'EUR'), Money(3, 'USD')]Value equality and hashing for an immutable object, with NotImplemented for foreign types. Equal money is one set member; a mutable version stays unhashable.
# eq_hash.py
class Money:
def __init__(self, amount, currency):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return (self.amount, self.currency) == (other.amount, other.currency)
def __hash__(self):
return hash((self.amount, self.currency))
a, b = Money(5, "EUR"), Money(5, "EUR")
print(a == b, a is b, a == "5 EUR")
print(len({a, b, Money(7, "EUR")}))
print({a: "coffee"}[b])
class MutablePoint:
def __init__(self, x):
self.x = x
def __eq__(self, other):
return isinstance(other, MutablePoint) and self.x == other.x
print(MutablePoint(1) == MutablePoint(1))
{MutablePoint(1)}
True False False
2
coffee
True
Traceback (most recent call last):
File "eq_hash.py", line 33, in <module>
{MutablePoint(1)}
TypeError: cannot use 'MutablePoint' as a set element (unhashable type: 'MutablePoint')Ordering with total_ordering, plus arithmetic that returns new objects and refuses mismatched currencies cleanly.
# ordering_math.py
from functools import total_ordering
@total_ordering
class Money:
def __init__(self, amount, currency):
self.amount, self.currency = amount, currency
def __repr__(self):
return f"Money({self.amount}, {self.currency!r})"
def __eq__(self, other):
return isinstance(other, Money) and (self.amount, self.currency) == (other.amount, other.currency)
def __lt__(self, other):
if not isinstance(other, Money) or other.currency != self.currency:
return NotImplemented
return self.amount < other.amount
def __add__(self, other):
if not isinstance(other, Money) or other.currency != self.currency:
return NotImplemented
return Money(self.amount + other.amount, self.currency)
def __mul__(self, factor):
return Money(self.amount * factor, self.currency)
__rmul__ = __mul__
wallet = [Money(5, "EUR"), Money(1, "EUR"), Money(3, "EUR")]
print(sorted(wallet), max(wallet))
print(Money(1, "EUR") <= Money(1, "EUR"), Money(2, "EUR") > Money(1, "EUR"))
print(Money(2, "EUR") + Money(3, "EUR"), 3 * Money(2, "EUR"))
print(Money(1, "EUR") + Money(1, "USD"))
[Money(1, 'EUR'), Money(3, 'EUR'), Money(5, 'EUR')] Money(5, 'EUR')
True True
Money(5, 'EUR') Money(6, 'EUR')
Traceback (most recent call last):
File "ordering_math.py", line 35, in <module>
print(Money(1, "EUR") + Money(1, "USD"))
~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~
TypeError: unsupported operand type(s) for +: 'Money' and 'Money'A container class: __len__, __getitem__ with slice support, __contains__, and __bool__, which together give len, indexing, slicing, in, iteration, and truthiness.
# container.py
class Deck:
def __init__(self, cards):
self._cards = list(cards)
def __len__(self):
return len(self._cards)
def __getitem__(self, index):
result = self._cards[index]
return Deck(result) if isinstance(index, slice) else result
def __contains__(self, card):
return card in self._cards
def __repr__(self):
return f"Deck({self._cards})"
deck = Deck(["A", "K", "Q", "J", "10"])
print(len(deck), deck[0], deck[-1])
print(deck[1:3])
print("Q" in deck, "2" in deck)
print([card.lower() for card in deck])
print(bool(deck), bool(Deck([])))
5 A 10
Deck(['K', 'Q'])
True False
['a', 'k', 'q', 'j', '10']
True FalseTry it yourself
Create vector.py with an immutable Vector(x, y) class. Give it a __repr__, value __eq__ and __hash__, __add__ and __sub__ between vectors, __mul__ and __rmul__ by a number, __abs__ for the length, and __bool__ that is False for the zero vector. Sort a list of vectors by length using key=abs, put duplicates in a set to de-duplicate them, and check that adding a vector to a string raises a clean TypeError.
Common mistakes
- Defining
__eq__without__hash__on an immutable value class. It silently becomes unhashable and disappears from sets and dictionary keys. - Mutating in
__add__. Arithmetic dunders return new objects;self.amount += ...breaks every expression that uses the original. - Raising
TypeErroryourself for unknown operands. ReturnNotImplementedso Python can try the reflected method and produce the standard error.
What's next
- Operators and built-ins call dunder methods; implement them and your objects behave like built-ins.
__repr__for developers,__str__for users;__eq__with__hash__for immutable values,__lt__withtotal_orderingfor sorting.__len__,__getitem__, and__contains__make containers; arithmetic dunders return new objects andNotImplementedfor unsupported types.
Your classes can speak the language's own vocabulary. Next you will stop writing that boilerplate by hand: dataclasses generate it, and Enum names your fixed choices.