Python has roughly 100 dunder methods documented in the Data Model reference, but you'll use about 12 of them daily and the rest almost never. Beginners worry about memorizing all of them; intermediate developers worry about which to implement on custom classes. The honest answer: focus on the 12 that connect your class to Python's syntax (the + operator, the in keyword, the for loop, the with statement, the len() function), and reach for the others only when a specific need shows up. This entry walks through each of the 12, what triggers it, how to implement it correctly, and the pairing rules that catch most developers off guard. One of 10 explainers in our Python OOP complete guide.
Key Takeaways
Dunders aren't magic. They're regular methods Python calls implicitly when you use built-in syntax: len(x) calls x.__len__(), x + y calls x.__add__(y).
Always define __repr__ on custom classes for debuggable output. Define __str__ only when the user-facing representation differs from the developer one.
Pair __eq__ with __hash__ — defining one without the other makes instances unhashable or causes dict/set bugs.
The container protocol is four dunders:__len__, __getitem__, __contains__, __iter__. Implement what your container actually supports.
__enter__ and __exit__ always come together — that's what makes with blocks work. Use contextlib.contextmanager when you don't need a class.
Each card pairs the Python syntax you write with the dunder it dispatches to. Five categories cover the 12 daily-use methods.
Group 1: Lifecycle
__init__ — the constructor
__init__ runs right after a new instance is created. You set up attributes here. It does NOT create the object — __new__ does that — but you almost never need to override __new__ outside of immutable types like tuple or metaclass tricks.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
print(p.x, p.y) # 3 4
Covered in depth in the OOP for beginners explainer; mentioned here for completeness because it's part of the standard dunder vocabulary.
Group 2: String Representation
Two dunders, one rule: __str__ pretty, __repr__ precise.
__repr__ — for developers
__repr__ returns the unambiguous developer-facing string. The convention from the PSF Data Model: if possible, repr(obj) should look like a valid Python expression that would recreate the object.
If you skip __str__, Python falls back to __repr__ for both. That's fine when the two representations would be identical. Define __str__ only when the user-facing string differs from the developer one.
Group 3: Equality and Hashing
The most error-prone pair. Define one without the other and you'll get subtle bugs.
__eq__ — value equality
By default, obj1 == obj2 is the same as obj1 is obj2 (identity comparison). Override __eq__ to compare by value.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented # Let Python try the other side
return self.x == other.x and self.y == other.y
print(Point(3, 4) == Point(3, 4)) # True
print(Point(3, 4) == "hello") # False (and no error)
The NotImplemented return (not False) tells Python to try the reflected operation on the other object. Returning False directly would shortcut that, breaking interop with numeric types and proxy objects.
__hash__ — required for dict and set use
Here's the rule that bites everyone: defining __eq__ automatically sets __hash__ to None, making instances unhashable.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return isinstance(other, Point) and self.x == other.x and self.y == other.y
s = {Point(3, 4)}
# TypeError: unhashable type: 'Point'
Fix: define __hash__ alongside __eq__, hashing the same fields used for equality.
Should return a non-negative integer. Bonus: an object with __len__ is automatically truthy when length is non-zero, falsy when length is zero — without you writing __bool__.
__getitem__ — enables obj[index]
class Deck:
def __init__(self):
self._cards = list(range(52))
def __getitem__(self, index):
return self._cards[index]
d = Deck()
print(d[0]) # 0
print(d[10:13]) # [10, 11, 12] — slicing works free if you forward to a list
If your underlying storage supports slicing, forwarding to it (self._cards[index]) handles both single-index and slice access automatically. Otherwise check isinstance(index, slice) and handle each case.
Useful side effect: if you define __getitem__ but skip __iter__, Python falls back to calling obj[0], obj[1], obj[2]... until IndexError. This is the legacy iteration protocol — it works, but explicit __iter__ is better.
__contains__ — enables `in`
class Deck:
def __init__(self):
self._cards = set(range(52))
def __contains__(self, item):
return item in self._cards
print(7 in Deck()) # True
If you skip __contains__ but define __iter__ or __getitem__, Python falls back by iterating — O(n) instead of O(1) for a set. Define __contains__ explicitly when membership is faster than iteration.
__iter__ — enables for loops
class Deck:
def __init__(self):
self._cards = list(range(52))
def __iter__(self):
yield from self._cards
for card in Deck():
print(card)
The cleanest pattern: make __iter__ a generator function (use yield or yield from). The generator IS the iterator; no separate __next__ needed. For finer control, return an explicit iterator object that has both __iter__ (returning self) and __next__ (raising StopIteration when done).
Stateful function objects: the counter above. Function with memory.
Parameterized decorators: a decorator class whose __init__ takes config and whose __call__ wraps the target function.
Strategy pattern: the strategy object IS the operation.
Under the hood, every Python class is callable because the metaclass type defines __call__ — that's what runs __new__ and __init__ when you write MyClass(...). Covered in the metaclasses explainer.
Group 6: Context Managers
__enter__ and __exit__ — the `with` block
These two come as a pair. Together they make your class usable in with blocks.
class Timer:
def __enter__(self):
from time import perf_counter
self.start = perf_counter()
return self # Bound to `as` clause
def __exit__(self, exc_type, exc_val, exc_tb):
from time import perf_counter
self.elapsed = perf_counter() - self.start
print(f"Elapsed: {self.elapsed:.3f}s")
return False # Don't suppress exceptions
with Timer() as t:
sum(i * i for i in range(10_000_000))
# Elapsed: 0.42s
print(t.elapsed) # 0.42
Key rules:
__enter__ runs at block entry; its return value is bound to the as clause.
__exit__ ALWAYS runs at block exit — normal completion OR exception. The three args (exc_type, exc_val, exc_tb) describe the exception, or are all None on normal exit.
Return True from __exit__ to suppress the exception; False (or None) lets it propagate.
For simple cases, contextlib.contextmanager turns a generator function into a context manager without a class:
from contextlib import contextmanager
from time import perf_counter
@contextmanager
def timer():
start = perf_counter()
yield
print(f"Elapsed: {perf_counter() - start:.3f}s")
with timer():
sum(i * i for i in range(10_000_000))
Use the decorator form for quick one-offs; reach for a class when you need state, methods, or inheritance.
Pairing Rules at a Glance
If you define
Also define
Why
__eq__
__hash__
Equal objects must hash equal; otherwise dict/set break.
__enter__
__exit__
Both are required to participate in with.
__lt__ (ordering)
__eq__ + others (or @total_ordering)
Sorting and comparisons need a consistent set.
__getattr__
Consider __getattribute__ caveat
One runs only when normal lookup fails; the other runs every time.
Ordering: __lt__ and the total_ordering Shortcut
If your class needs to be sortable or to support <, <=, >, >= comparisons, you need ordering dunders. The full set is __lt__, __le__, __gt__, __ge__ (plus __eq__ for completeness). Implementing all five is tedious, so the standard library ships a shortcut: functools.total_ordering derives the rest from __eq__ plus any ONE of the comparison dunders.
The decorator fills in the missing comparisons, so callers can write v1 > v2 even though you only defined __lt__. If raw speed matters, implementing all four directly avoids the per-call overhead of the derived versions, but for typical code total_ordering is the right balance. The same pairing rule from equality applies: __lt__ should also return NotImplemented when given an incomparable type, not raise TypeError itself.
Skip-or-Use Heuristic
Don't try to implement every dunder Python documents. Use this rough decision tree:
Always:__init__, __repr__ on any custom class.
When your class represents a value:__eq__ + __hash__.
When your class is a container:__len__, plus whichever of __getitem__ / __contains__ / __iter__ are natural.
When your class manages a resource:__enter__ + __exit__.
When your class IS the operation:__call__.
When user output differs from debug output: add __str__.
Everything else — __getattr__, __setattr__, __delattr__, __add__, __mul__, ordering dunders, copy hooks — comes up sometimes but is rarely the right starting point.
Five Mistakes That Bite Even Experienced Developers
1. Returning False instead of NotImplemented from __eq__
The pattern that looks defensive but breaks numeric interop: hard-coding return False for any mismatched type. The right choice is NotImplemented, which lets Python try the operation on the other operand. This matters whenever your class might be compared against unrelated types (a custom Money compared to Decimal, for example) and you want the OTHER side's __eq__ to get a chance.
2. Skipping __repr__ and living with useless tracebacks
A class without __repr__ shows up in errors as <__main__.Order object at 0x7f8e2b40>. When a test fails or a logger fires, you can't tell which order broke. Three lines of __repr__ turn that into Order(id='ORD-42', total=99.50) — the difference between two minutes and two hours of debugging.
3. Infinite recursion in __getattr__
__getattr__ is called when normal attribute lookup fails. If your implementation accesses self.something_missing internally, it calls __getattr__ again, and again, and again. Stack overflow. The fix is to access self.__dict__ or call object.__getattribute__(self, name) directly to bypass the dunder.
4. __iter__ returning the wrong object
__iter__ should return an iterator, not the iterable itself. A list isn't its own iterator; that's why iter(my_list) returns a separate list_iterator object. If you make your container's __iter__ return self, two simultaneous loops over the same object share state and break. The generator pattern (def __iter__(self): yield from self._items) sidesteps this entirely.
5. Forgetting that __getitem__ might get a slice
If your class is indexable, callers can also pass slices: obj[1:5]. The index argument arrives as a slice object, not an integer. Forwarding to a list (return self._items[index]) handles both transparently; rolling your own logic requires isinstance(index, slice) handling. The list-forwarding pattern works for 90% of cases — reach for explicit slice handling only when your container has unusual indexing.
40 lines, 7 dunders, and the class plugs into print(), +, abs(), bool(), and set() without any other plumbing. In other words, a small set of well-chosen dunders turns a plain class into something that participates fully in Python's built-in syntax — which is exactly the value proposition of the data model.
Don't call dunders directly. Writing obj.__len__() works but it's bad style: prefer len(obj), and similarly use obj + other instead of obj.__add__(other). The built-in functions and operators handle subtle edge cases — like checking for NotImplemented and trying the reflected operation on the other operand — that direct dunder calls skip entirely.
Frequently Asked Questions
What is the difference between __str__ and __repr__ in Python?
__repr__ is for developers and should be unambiguous; __str__ is for end users and should be readable. The rule of thumb: __str__ pretty, __repr__ precise.__repr__ ideally returns a string that, if evaluated, would recreate the object — for example, repr(p) might return 'Point(x=3, y=4)'. __str__ is what print(obj) shows; it can be friendlier, like 'Point at (3, 4)'. If you define __repr__ but skip __str__, Python falls back to __repr__ for str(obj) too, which is fine. ALWAYS define __repr__ on custom classes; define __str__ only when the user-facing representation differs from the developer one.
Why does my Python class become unhashable when I define __eq__?
Python's data model has a strict rule: objects that compare equal must hash equal. If you override __eq__ without overriding __hash__, Python automatically sets __hash__ to None, making the class unhashable and unable to be used as a dict key or in a set. The fix is to either define __hash__ explicitly (return a hash of the same fields you compared in __eq__), or use @dataclass(eq=True, frozen=True) which auto-generates both correctly, or set __hash__ = None explicitly if mutability makes hashing unsafe. The pairing rule isn't arbitrary; it's required for dict and set correctness.
What dunder methods does a Python for loop need?
The for loop calls __iter__ on the object, which should return an iterator. The iterator itself needs __next__, which returns the next value or raises StopIteration when exhausted. If a class defines __getitem__ but not __iter__, Python falls back to calling obj[0], obj[1], obj[2]... until IndexError is raised. This is the "old-style iteration protocol"; it works but is slower and less explicit than __iter__. Modern code defines __iter__ directly. The simplest implementation is to make __iter__ a generator function: def __iter__(self): yield from self._items.
Can I make a Python class instance callable like a function?
Yes — define __call__(self, ...) and the instance can be called as instance(args). This is useful for stateful function objects (a counter that remembers how many times it's been called), parameterized decorators (a decorator class that takes config), and strategy patterns where the strategy object IS the function. Under the hood, Python's call expression instance() dispatches to type(instance).__call__(instance, ...). Note that classes themselves are callable because the metaclass type defines __call__, which is what runs __new__ and __init__ when you write MyClass(...).
Are dunder methods really "magic" or just regular methods?
They are regular methods with reserved names; the "magic" is that the Python language and built-in functions call them implicitly. len(x) calls x.__len__(), x + y calls x.__add__(y), with x: calls x.__enter__(), and so on. Nothing stops you from calling them directly (obj.__len__() works), but it's bad style — call len(obj) instead. The dunder mechanism is the foundation of Python's data model: any object can participate in language-level operations by implementing the right dunder. That's why custom containers can use [] indexing, why custom numbers can use +, and why custom resources can use with.
The Bottom Line: 12 Dunders, Five Categories, Predictable Behavior
Dunders aren't magic. They're the bindings between your class and Python's built-in syntax. Define __init__ and __repr__ on every class. Pair __eq__ with __hash__. Implement the container dunders (__len__, __getitem__, __contains__, __iter__) when your class IS a container, not because the documentation lists them. Use __call__ for stateful function objects. Always pair __enter__ with __exit__. Everything else is either an edge case or a refinement; ignore it until a real need shows up. Next entry: composition vs inheritance in Python — when to favor each. Or browse the full Python OOP guide.
Make Dunders Reflex Through 800+ Tasks
CodeGym's Python track works through dunder patterns at the right cadence: container protocols, equality/hash pairing, context managers, and callable instances across 62 levels of hands-on tasks. AI validators catch the __eq__-without-__hash__ trap before it ships; AI mentors explain why your custom container won't iterate. First level free; full plan on the pricing page.
A single lucky spin on casino slots can instantly turn a small balance into a mind-blowing cash prize if you hit the bonus round. To quickly discover trustworthy casino portals where every prediction brings you closer to real wins and withdrawals are processed without long delays, visiting NajboljseIgralnice provides web users with very simple facts about the hot trends of the season. Highly rated casino brands in Slovenia secure your profile information completely.
GO TO FULL VERSION