python magic methods — also called python dunder methods, short for "double underscore" — are what let a print(obj) call, a + operator, or a len() call all work correctly on objects of your own custom classes. This article covers the most important ones: string representation, comparisons, arithmetic overloading, and the container/callable protocols that make your objects behave like Python's own built-in types.
Introduction: The Hooks Behind Python's Syntax
Dunder methods are special methods, named with double underscores on both sides (__method__), that Python calls automatically behind familiar syntax you already use constantly. print(obj) triggers obj.__str__(). obj + other triggers obj.__add__(other). len(obj) triggers obj.__len__().
Why this matters
Dunder methods are precisely how custom classes integrate seamlessly with Python's built-in operators and functions, rather than requiring special-cased handling scattered throughout your code. Without them, every custom class would need its own bespoke methods (.add(other), .to_string()) that behave completely differently from Python's native syntax — dunder methods let your own objects participate in the exact same +, print(), len(), in syntax that every built-in type already uses.
A quick way to explore them
Every built-in type already implements a large set of these — you can see them directly:
print(dir(int))
# ['__abs__', '__add__', '__and__', '__bool__', '__ceil__', ...]Scanning through the output for any built-in type gives you a genuine sense of just how much of Python's "native" behavior is actually implemented through this same dunder method mechanism, all the way down.
String Representation: str and repr
The distinction
Two methods control how an object turns into a string, and they serve genuinely different purposes:
__repr__should be unambiguous — ideally, something that could be used to recreate the object if pasted back into Python. It's aimed primarily at developers, especially when debugging.__str__is a readable, user-facing display — meant for people actually using the program, not necessarily anyone debugging its internals.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})" # unambiguous, debugger-friendly
def __str__(self):
return f"({self.x}, {self.y})" # clean, user-facing display
p = Point(3, 4)
print(p) # (3, 4) — uses __str__
print(repr(p)) # Point(x=3, y=4) — uses __repr__Fallback behavior
If a class defines __repr__ but not __str__, Python automatically falls back to using __repr__ wherever __str__ would otherwise be needed:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
# no __str__ defined
p = Point(3, 4)
print(p) # Point(x=3, y=4) — falls back to __repr__, since __str__ isn't definedThis is a genuinely practical reason to always implement __repr__ on a custom class, even if you also plan to implement __str__ separately — it acts as a sensible fallback, and it's directly useful anytime you're inspecting an object in a debugger, a REPL session, or inside a list's printed output (which always uses each item's __repr__, never its __str__).
Comparison Methods: eq, lt, and total_ordering
Giving meaning to ==
By default, == on custom objects falls back to identity comparison — effectively behaving like is, checking whether two variables point to the exact same object, rather than comparing their actual data:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # False — default behavior, compares identity, not valuesImplementing __eq__ lets you define what "equal" genuinely means for your class:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # True — now compares actual coordinate valuesThe eq / hash pairing
This is a genuinely important pitfall worth knowing well: implementing __eq__ without also implementing __hash__ makes your objects unhashable — Python automatically sets __hash__ to None on any class that defines __eq__ but doesn't also explicitly define __hash__, which breaks that class's ability to be used in a set or as a dictionary key:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# no __hash__ defined
p = Point(1, 2)
my_set = {p}
# TypeError: unhashable type: 'Point'If you need instances of a class to remain hashable after implementing __eq__, you need to explicitly define __hash__ too — typically based on the same fields used in the equality check:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
p1 = Point(1, 2)
my_set = {p1} # works correctly nowfunctools.total_ordering
Defining every comparison method (__lt__, __le__, __gt__, __ge__) individually is repetitive. functools.total_ordering lets you define just __eq__ and one ordering method — commonly __lt__ — and it automatically generates the rest:
from functools import total_ordering
@total_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
amounts = [Money(50), Money(10), Money(30)]
sorted_amounts = sorted(amounts) # works correctly — sorted() relies on comparison methods
print([m.amount for m in sorted_amounts]) # [10, 30, 50]@total_ordering filled in __le__, __gt__, and __ge__ automatically, purely from the __eq__ and __lt__ you defined directly — which is exactly what let sorted() work correctly on a list of Money objects, without you needing to write all four comparison methods by hand.
Best practice: return NotImplemented
When a comparison method receives an operand of a type it genuinely doesn't know how to compare against, the correct response is NotImplemented (a special built-in singleton value) — not False, and not raising an exception directly:
class Money:
def __init__(self, amount):
self.amount = amount
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amountReturning NotImplemented tells Python "I don't know how to handle this comparison — please try the other object's reflected method instead" (for instance, other.__eq__(self)), giving the other type a genuine chance to handle the comparison correctly, rather than Python assuming a false answer purely because the first object's method happened to give up.
Arithmetic Operator Overloading
Making custom objects work with + , -, *
Implementing __add__, __sub__, __mul__, and similar dunder methods lets a custom, numeric-like class (money, vectors, physical distances) work naturally with Python's arithmetic operators, exactly as covered briefly in the earlier polymorphism article:
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)
print(v1 + v2) # Vector(4, 6)A practical example with validation: Money
class Money:
def __init__(self, amount, currency="USD"):
self.amount = amount
self.currency = currency
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Cannot add {self.currency} and {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __repr__(self):
return f"{self.amount} {self.currency}"
usd1 = Money(50, "USD")
usd2 = Money(30, "USD")
print(usd1 + usd2) # 80 USD
eur = Money(20, "EUR")
usd1 + eur
# ValueError: Cannot add USD and EURThis is a genuinely realistic use case — __add__ enforces a real business rule (matching currencies) as part of the operator overload itself, meaning usd1 + eur fails loudly and immediately, rather than silently producing a nonsensical combined amount.
The same NotImplemented convention applies
Just as with comparison methods, returning NotImplemented from an arithmetic dunder method (rather than raising an exception directly, or silently returning something incorrect) lets Python fall back and try the other operand's reflected method — __radd__ in the case of addition — giving mixed-type arithmetic a genuine chance to work correctly when it reasonably should.
Container and Callable Protocols
len, getitem, and contains
These three dunder methods let a custom object behave like a built-in sequence — supporting len(), bracket indexing, and the in operator:
class Playlist:
def __init__(self, songs):
self.s songs
def __len__(self):
return len(self.songs)
def __getitem__(self, index):
return self.songs[index]
def __contains__(self, song):
return song in self.songs
playlist = Playlist(["Song A", "Song B", "Song C"])
print(len(playlist)) # 3 — len() works
print(playlist[1]) # Song B — bracket indexing works
print("Song A" in playlist) # True — the in operator worksNone of len(), playlist[1], or "Song A" in playlist would work at all on a plain custom class without these three methods explicitly defined — they're what makes Playlist genuinely feel like a native Python sequence from the outside, even though it's backed by a completely ordinary custom class internally.
bool and its fallback to len
__bool__ controls how an object behaves in a truthiness check (if obj:). If __bool__ isn't defined, Python falls back to __len__ instead — an object with a length of 0 is treated as falsy, and any nonzero length is treated as truthy:
class Playlist:
def __init__(self, songs):
self.s songs
def __len__(self):
return len(self.songs)
empty_playlist = Playlist([])
full_playlist = Playlist(["Song A"])
print(bool(empty_playlist)) # False — falls back to __len__, which is 0
print(bool(full_playlist)) # True — __len__ is 1, nonzero
if empty_playlist:
print("Has songs")
else:
print("Empty playlist")
# Empty playlistThis mirrors exactly the truthy/falsy behavior covered for built-in types (like empty lists and strings) in the earlier booleans article — __bool__/__len__ are what actually implement that behavior under the hood, and defining them on your own classes extends the exact same pattern to your custom objects.
call: making instances callable
__call__ lets you call an instance of a class directly, using regular function-call syntax:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
print(double(5)) # 10 — calling the instance directly, like a functionThis is genuinely useful for stateful callables — objects that behave like a function from the outside, but internally maintain some configuration or state, exactly like factor here. It's also precisely the mechanism behind class-based decorators, covered in the earlier decorators article — the CallCounter class shown there relies entirely on __call__ to make its instances behave like a wrapped function.
Closing guidance
Implement dunder methods gradually, and only where they genuinely add clarity — not reflexively, on every class you write, purely because you now know they exist. A class that clearly represents something numeric, sequence-like, or naturally comparable is a good candidate for the relevant dunder methods; a class that doesn't naturally fit any of those patterns generally shouldn't force them in artificially. When you're implementing one and aren't sure of the exact expected behavior or signature, Python's official documentation — specifically the "Special Method Names" section of the data model reference — remains the definitive, authoritative source for getting the details right.