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.
The 12 essential Python dunders, organized by what triggers them The 12 essential dunders: what you write → what Python calls 5 CATEGORIES · 12 METHODS · EVERY BUILT-IN OPERATION DISPATCHES HERE LIFECYCLE MyClass(args) → __init__ runs on construction Pairs with __new__ for advanced object creation, but you rarely need it. STRING REPRESENTATION repr(obj) → __repr__ unambiguous, eval-able print(obj) · str(obj) → __str__ readable, user-facing EQUALITY & HASH obj == other → __eq__ value comparison hash(obj) → __hash__ required for dict / set use CONTAINER PROTOCOL four dunders that let your class participate in Python's container operations len(obj) → __len__ non-negative int also makes obj truthy/falsy obj[5] → __getitem__ indexing + slicing enables fallback iteration too 5 in obj → __contains__ membership test faster than the __getitem__ fallback for x in obj → __iter__ returns iterator simplest form: yield from items CALLABLE obj(args) → __call__ instances behave like functions; useful for stateful callables, parameterized decorators CONTEXT MANAGER with obj as x: → __enter__ + __exit__ always paired; __exit__ runs even when the block raises — return True to suppress
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.
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

p = Point(3, 4)
print(repr(p))   # Point(x=3, y=4)
print([p, p])    # [Point(x=3, y=4), Point(x=3, y=4)]
The default __repr__ looks like <__main__.Point object at 0x7f...> — useless in logs and tracebacks. Override it on every custom class you write.

__str__ — for end users

__str__ is what print() and str() call. It's the friendly version.
class Point:
    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

    def __str__(self):
        return f"({self.x}, {self.y})"

p = Point(3, 4)
print(p)         # (3, 4)  — uses __str__
print(repr(p))   # Point(x=3, y=4)  — uses __repr__
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.
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, other):
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)

    def __hash__(self):
        return hash((self.x, self.y))

s = {Point(3, 4), Point(3, 4)}
print(len(s))   # 1 — equal points collapse
The contract Python enforces: if a == b then hash(a) == hash(b). Break this and dicts and sets give wrong answers. Two cleanest paths to follow:
  • Pair __eq__ with a matching __hash__ based on the same fields.
  • Use @dataclass(eq=True, frozen=True) — it generates both correctly. Covered in the dataclass deep dive.
If the object is mutable and shouldn't be hashable, set __hash__ = None explicitly to make the choice intentional.

Group 4: The Container Protocol

Four dunders connect your class to Python's container operations. Implement only the ones your class actually supports.

__len__ — enables len()

class Deck:
    def __init__(self):
        self._cards = list(range(52))

    def __len__(self):
        return len(self._cards)

print(len(Deck()))   # 52
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).

Group 5: Callable Instances

__call__ — instances behave like functions

class Counter:
    def __init__(self):
        self.count = 0

    def __call__(self):
        self.count += 1
        return self.count

tick = Counter()
print(tick())   # 1
print(tick())   # 2
print(tick())   # 3
Three common uses:
  • 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 defineAlso defineWhy
__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__ caveatOne 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.
from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major, minor, patch):
        self.tuple = (major, minor, patch)

    def __eq__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return self.tuple == other.tuple

    def __lt__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return self.tuple < other.tuple

    def __hash__(self):
        return hash(self.tuple)

versions = [Version(1, 9, 0), Version(2, 0, 1), Version(1, 10, 0)]
print(sorted(versions))   # [Version(1, 9, 0), Version(1, 10, 0), Version(2, 0, 1)]
print(Version(2, 0, 1) >= Version(1, 9, 0))   # True — derived from __lt__
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.

Real-World Example: A Tiny Vector

Putting several dunders together:
from math import sqrt

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

    def __eq__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

    def __hash__(self):
        return hash((self.x, self.y))

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)

    def __abs__(self):
        return sqrt(self.x ** 2 + self.y ** 2)

    def __bool__(self):
        return bool(abs(self))

v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)        # Vector(4, 6)
print(abs(v1))        # 5.0
print(bool(Vector(0, 0)))   # False
print({v1, v1, v2})   # {Vector(3, 4), Vector(1, 2)}
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.