Python Magic Methods: Dunder Methods That Make Objects Feel Native
When you call len(some_list) or write a + b, Python does not have special-cased handling for built-in types. Instead, it calls a method on the object: some_list.__len__() and a.__add__(b). These dunder methods (double underscore, also called magic methods or special methods) are how Python makes its built-in syntax extensible. Any class can implement them, and Python will treat instances of that class like first-class objects.
__str__ and __repr__: Two Kinds of String Output
Both methods control how an object is converted to a string, but for different audiences.
__str__is for humans: readable, concise, suitable forprint().__repr__is for developers: precise, ideally something you could paste into the REPL to recreate the object.
class Vector: 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"Vector({self.x!r}, {self.y!r})"
v = Vector(3, 4)print(v) # (3, 4) — uses __str__print(repr(v)) # Vector(3, 4) — uses __repr__
# In interactive mode or debuggers, repr is shownvectors = [Vector(1, 0), Vector(0, 1)]print(vectors) # [Vector(1, 0), Vector(0, 1)] — __repr__ for items in a listIf you only implement one, implement __repr__. Python falls back to __repr__ when __str__ is missing. The reverse is not true.
__add__, __sub__, __mul__: Arithmetic Operators
Implementing __add__ makes the + operator work on your objects.
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 __sub__(self, other): return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar): return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar): # Handles scalar * vector (not just vector * scalar) return self.__mul__(scalar)
def __abs__(self): return (self.x ** 2 + self.y ** 2) ** 0.5
def __repr__(self): return f"Vector({self.x}, {self.y})"
a = Vector(1, 2)b = Vector(3, 4)
print(a + b) # Vector(4, 6)print(b - a) # Vector(2, 2)print(a * 3) # Vector(3, 6)print(3 * a) # Vector(3, 6) — calls __rmul__print(abs(b)) # 5.0The __r*__ variants (like __rmul__) handle cases where Python tries scalar.__mul__(vector) first, fails, and then tries vector.__rmul__(scalar).
__eq__, __lt__, __le__: Comparisons
By default, == checks identity (same object in memory). Override __eq__ to compare by value.
from functools import total_ordering
@total_ordering # generates __le__, __gt__, __ge__ from __eq__ and __lt__class Temperature: def __init__(self, celsius): self.celsius = celsius
def __eq__(self, other): if not isinstance(other, Temperature): return NotImplemented return self.celsius == other.celsius
def __lt__(self, other): if not isinstance(other, Temperature): return NotImplemented return self.celsius < other.celsius
def __repr__(self): return f"Temperature({self.celsius}°C)"
temps = [Temperature(37), Temperature(100), Temperature(0), Temperature(22)]print(sorted(temps))# [Temperature(0°C), Temperature(22°C), Temperature(37°C), Temperature(100°C)]
print(Temperature(37) == Temperature(37)) # Trueprint(Temperature(37) > Temperature(0)) # True@total_ordering from functools lets you define just __eq__ and __lt__, and it derives the other four comparison methods automatically.
Returning NotImplemented (not False) when the other object is of an incompatible type tells Python to try the reverse comparison on the other object.
__len__ and __getitem__: Making Objects Behave Like Sequences
class Deck: SUITS = ("♠", "♥", "♦", "♣") RANKS = list(range(2, 11)) + ["J", "Q", "K", "A"]
def __init__(self): self._cards = [ f"{rank}{suit}" for suit in self.SUITS for rank in self.RANKS ]
def __len__(self): return len(self._cards)
def __getitem__(self, index): return self._cards[index]
deck = Deck()print(len(deck)) # 52print(deck[0]) # 2♠print(deck[-1]) # A♣print(deck[5:8]) # ['7♠', '8♠', '9♠']
# __len__ and __getitem__ also make the object iterablefor card in deck[:3]: print(card)Once you implement __getitem__, objects of that class become iterable (Python calls __getitem__ with increasing indices until it gets an IndexError). You also get slicing for free if the underlying data structure supports it.
__iter__ and __next__: Explicit Iterator Protocol
For more control, implement the full iterator protocol:
class CountUp: def __init__(self, start, stop): self.current = start self.stop = stop
def __iter__(self): return self # this object is its own iterator
def __next__(self): if self.current > self.stop: raise StopIteration value = self.current self.current += 1 return value
for n in CountUp(1, 5): print(n, end=" ")# 1 2 3 4 5__enter__ and __exit__: Context Managers
These two methods make your class work with the with statement.
class Timer: def __enter__(self): import time self._start = time.perf_counter() return self
def __exit__(self, exc_type, exc_val, exc_tb): import time self.elapsed = time.perf_counter() - self._start print(f"Elapsed: {self.elapsed:.4f}s") return False # do not suppress exceptions
with Timer() as t: total = sum(range(1_000_000))
# Elapsed: 0.0234s__exit__ receives exception information if one occurred. Returning True suppresses the exception; returning False (or None) lets it propagate.
Best Practices
Return NotImplemented from comparison and arithmetic methods when the types are incompatible — do not return False or raise TypeError yourself. This allows Python to try the reflected operation on the other object.
Keep dunder methods simple. They are called by Python’s infrastructure. Surprising side effects in __len__ or __eq__ make code very hard to debug.
Do not implement operator overloading unless it makes semantic sense. Vector + Vector is intuitive. User + User is probably not.
Always implement __repr__ on classes you create. The default <__main__.MyClass object at 0x7f...> is nearly useless during debugging.