Dunder / Magic Methods
"Dunder" is short for double underscore. Dunder methods (also called magic methods or special methods) are methods with names like __init__, __str__, and __add__ — surrounded on both sides by double underscores. Python calls these methods automatically in response to built-in syntax and built-in functions, instead of you having to call them by name yourself.
This is what lets your own classes plug directly into Python's native syntax. Instead of inventing a method called add_vectors and forcing everyone to call v1.add_vectors(v2), you implement __add__, and suddenly plain v1 + v2 just works. Instead of a custom to_display_string method, you implement __str__, and print(obj) and str(obj) just work. Dunder methods are the glue between your objects and Python's operators, len(), indexing, iteration, comparisons, and more.
`__str__` vs `__repr__`
Two of the most common dunder methods control how an object turns into text, but they serve different audiences. __str__ is meant to produce a readable, user-friendly description — it's what print() and str() use. __repr__ is meant to produce an unambiguous, developer-facing representation, ideally one that looks like valid Python code that could recreate the object. It's what the interactive REPL shows, and it's also what's used when displaying objects inside containers like lists.
Method | Called By | Purpose | Fallback Behavior |
|---|---|---|---|
|
| Readable, user-facing description | Falls back to |
| REPL echo, | Unambiguous, debug-friendly representation | Falls back to default like |
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"({self.x}, {self.y})"
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
p = Point(3, 4)
print(p) # uses __str__
print([p]) # uses __repr__ for the item inside the list
print(repr(p)) # uses __repr__ directly(3, 4) [Point(x=3, y=4)] Point(x=3, y=4)
`__eq__` and `__lt__`: Comparisons
By default, Python compares custom objects by identity — two separate instances are never considered equal, even with identical data. __eq__ lets you define what "equal to" means for your class, controlling the == operator. __lt__ defines "less than," controlling the less than operator, and it's the method Python's sorting functions (sorted(), .sort()) rely on to decide ordering.
class Money:
def __init__(self, amount):
self.amount = amount
def __eq__(self, other):
return self.amount == other.amount
def __lt__(self, other):
return self.amount < other.amount
def __repr__(self):
return f"Money({self.amount})"
wallet = [Money(50), Money(5), Money(20)]
print(Money(10) == Money(10)) # uses __eq__
print(sorted(wallet)) # uses __lt__ to order the listTrue [Money(5), Money(20), Money(50)]
Without __eq__, Money(10) == Money(10) would return False, because Python would be comparing two distinct objects by identity rather than by their amount values. Without __lt__, calling sorted() on a list of Money objects would raise a TypeError, since Python wouldn't know how to decide which one comes first.
`__len__` and `__getitem__`
__len__ lets your object respond to the built-in len() function. __getitem__ lets your object support square-bracket indexing, obj[i], which is what makes a custom class feel like a real collection. Here's a small custom collection that implements both:
class Playlist:
def __init__(self, songs):
self.songs = songs
def __len__(self):
return len(self.songs)
def __getitem__(self, index):
return self.songs[index]
playlist = Playlist(["Intro", "Solo", "Outro"])
print(len(playlist)) # uses __len__
print(playlist[0]) # uses __getitem__
print(playlist[-1]) # negative indexing works too
for song in playlist: # iteration works via repeated __getitem__ calls
print(song)3 Intro Outro Intro Solo Outro
The for loop at the end works even though Playlist never defined an __iter__ method or anything iteration-specific. As a bonus effect of implementing __getitem__, Python falls back to calling it with 0, 1, 2, and so on until it hits an IndexError, which is enough to make the object iterable for free.
`__add__`: A Full Operator Overloading Example
__add__ controls what the + operator does with your objects, letting you overload it with behavior that makes sense for your class. Here's a Vector class that supports adding two vectors together with plain + syntax:
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(2, 3)
v2 = Vector(4, 1)
v3 = v1 + v2 # Python calls v1.__add__(v2) behind the scenes
print(v3)Vector(6, 4)
Writing v1 + v2 triggers Python to call v1.__add__(v2) automatically. That method returns a brand-new Vector built from the sum of each vector's x and y components, and __repr__ makes the result print cleanly instead of showing a generic object address. Without __add__ defined, v1 + v2 would raise a TypeError telling you the + operator isn't supported between two Vector instances.
__str__/__repr__— control how an object is displayed as text.__eq__/__lt__— control==andless than, and enable sorting.__len__/__getitem__— supportlen()andobj[index], with iteration as a side effect of the latter.__add__— and its many siblings like__sub__,__mul__— control arithmetic operators.