Dunder methods — short for double underscore, like __init__ and __str__ — are the hooks that let your objects take part in Python's built-in operations. You have already written __init__; now you will meet the ones that decide how an object prints, adds, and compares.
Define __str__, and Python uses it whenever someone calls print() on the object or converts it with str():
class Money:
def __init__(self, dollars):
self.dollars = dollars
def __str__(self):
return f'${self.dollars}'
print(Money(50)) # $50
You never call these methods yourself. Python calls them in response to print, +, ==, len(), and friends — which is why they are sometimes described as magic. They are not magic at all, just agreed-upon names Python looks for.
flowchart LR O["a + b"] --> M["a.__add__(b)"] --> R["new object"] style O fill:#3776ab,color:#fff
Two ways to display an object
__str__ is for people — a readable label used by print. __repr__ is for programmers — an unambiguous representation used by repr() and shown when a list contains your objects. If you write only one, write __repr__; it also serves as the fallback for str() when __str__ is missing.
Operators are dunder methods too. a + b calls a.__add__(b), and a == b calls a.__eq__(b). Define them and your objects gain + and ==:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
return self.x == other.x and self.y == other.y
print(Vector(1, 2) + Vector(3, 4) == Vector(4, 6)) # True
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __repr__(self):
return f'Vector({self.x}, {self.y})'
v1 = Vector(1, 2)
v2 = Vector(3, 4)
v3 = v1 + v2
print(v3)
print(v3.x, v3.y)
What you see when you print
If a class defines neither __str__ nor __repr__, printing an instance gives an ugly default like <__main__.Money object at 0x7f...> — useless to a human reader. Defining __str__ fixes what print shows, while __repr__ fixes what you see in the REPL and inside containers such as lists.
The two methods sit in a small fallback chain. print and str() call __str__; when it is missing, Python falls back to __repr__. That is why writing only __repr__ is safe — every display path eventually reaches it. A good __repr__ looks like the code you would type to rebuild the object, so Vector(4, 6) beats a vague phrase like Vector object, x=4, y=6.
flowchart LR
P["print(obj)"] --> S["str(obj)"]
S --> ST{"__str__ defined?"}
ST -- "yes" --> O1["calls __str__"]
ST -- "no - falls back" --> O2["calls __repr__"]
style S fill:#3776ab,color:#fff
style O2 fill:#b45309,color:#fff
More hooks: length, truth, and comparison
Dunders reach far beyond printing. Define __len__ and len(obj) works; define __bool__ and the object can be tested directly in an if. Comparisons are dunders too: __eq__ powers ==, __lt__ powers <, and once < exists, sorted() and min() just work on your objects with no extra argument.
This is the real payoff of operator overloading. You are not inventing new syntax — you are plugging your class into the tools Python already provides for built-in types. A Vector that supports + and == can be reasoned about at a glance, because it behaves like the numbers and strings the reader already knows:
print(len(container)) # calls container.__len__()
print(v1 == v2) # calls v1.__eq__(v2)
print(sorted(items)) # calls item.__lt__(other)
class Team:
def __init__(self, name, wins):
self.name = name
self.wins = wins
def __lt__(self, other):
return self.wins < other.wins
def __repr__(self):
return f'Team({self.name}, {self.wins})'
teams = [Team('b', 5), Team('a', 9), Team('c', 7)]
for t in sorted(teams):
print(t)
Give Temperature a __str__ method so that str(Temperature(21)) returns "21C".
class Temperature:
def __init__(self, degrees):
self.degrees = degrees
# define __str__(self)
str must return a string, not print one.
An f-string like f"{self.degrees}C" does the job.
class Temperature:
def __init__(self, degrees):
self.degrees = degrees
def __str__(self):
return f"{self.degrees}C"
Give Vector an __add__(self, other) that returns a new Vector with added coordinates, and a __repr__(self) that returns 'Vector(x, y)'.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
# define __add__(self, other) and __repr__(self)
add returns a brand-new Vector: Vector(self.x + other.x, ...).
repr returns f'Vector({self.x}, {self.y})'.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __repr__(self):
return f'Vector({self.x}, {self.y})'
What does this print? __str__ decides what print shows.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f'({self.x}, {self.y})'
p = Point(2, 3)
print(p)
print uses str(p), which calls str.
The f-string fills in 2 and 3.
This __repr__ prints instead of returning, so repr(m) fails. Fix it so repr(Money(5)) returns the string 'Money(5)'.
class Money:
def __init__(self, amount):
self.amount = amount
def __repr__(self):
print(f'Money({self.amount})') # bug: prints instead of returning
repr must return a string, not print one.
With no str, str() falls back to repr — which is why the last test also passes.
class Money:
def __init__(self, amount):
self.amount = amount
def __repr__(self):
return f'Money({self.amount})'
Give Score a __lt__(self, other) that compares by points, and a __repr__(self) that returns 'Score(name, points)'. Once __lt__ exists, sorted() orders Score objects with no key= argument.
class Score:
def __init__(self, name, points):
self.name = name
self.points = points
# define __lt__(self, other) and __repr__(self)
lt returns self.points < other.points so sorted can compare items.
repr returns f'Score({self.name}, {self.points})' — note the space after the comma.
class Score:
def __init__(self, name, points):
self.name = name
self.points = points
def __lt__(self, other):
return self.points < other.points
def __repr__(self):
return f'Score({self.name}, {self.points})'
Rich comparisons: Python does not guess
Defining __eq__ lets your objects use ==, and defining __lt__ lets them use <. What Python does not do is derive one from the other — giving your class < does not automatically grant <=, >, or ==. Each operator maps to its own dunder, and you must define the ones you actually want.
That sounds tedious, and it can be. The functools module ships a decorator called total_ordering that fills in the missing comparisons once you define __eq__ and one ordering method such as __lt__. Reach for it when your class has a natural ordering and you want the full set of comparison operators without writing each one by hand.
Why repr earns its keep
A good __repr__ pays off long after you write it. It is what you see when you inspect a list of your objects in the REPL, what prints in a traceback when something crashes, and what debugging tools display as you step through code. The default <Money object at 0x...> tells you almost nothing; a deliberate Money(50) tells you the type and the state at a glance.
The rule of thumb is to make __repr__ look like valid Python that could rebuild the object. If eval(repr(obj)) would reconstruct something equal to obj, you have written a strong one. That goal is not always reachable, but aiming for it keeps your objects legible under pressure.
Recap
- Dunder methods hook your objects into Python's built-in operations.
__str__is the human label forprint;__repr__is the programmer-facing form and the fallback forstr().a + bisa.__add__(b);a == bisa.__eq__(b)— define the dunder, gain the operator.__lt__unlockssorted()andmin();__len__unlockslen().- Prefer the operator (
a + b) over calling the dunder (a.__add__(b)) directly.
Next you will package several related classes into a module of your own and reuse code the way the standard library does.