Dunder methods — short for “double underscore” methods — are Python’s mechanism for letting your classes hook into the language’s built-in behavior. When you write len(obj), Python calls obj.__len__(). When you write a + b, Python calls a.__add__(b). Understanding dunders is the key to writing classes that feel native to Python.

What is a Dunder Method?

A dunder method has a name surrounded by double underscores: __init__, __str__, __add__, etc. Python defines dozens of these hooks. You override them in your classes to customize how instances behave with operators, built-in functions, and language constructs.

Initialization: __init__ and __repr__

Every class starts here:

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

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

v = Vector(3, 4)
print(v)
# Output: Vector(3, 4)

__repr__ returns a string that ideally could recreate the object. It’s what you see in the REPL and in debugging output.

String Representation: __str__ vs __repr__

__str__ is for human-readable output; __repr__ is for developers:

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

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

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

v = Vector(3, 4)
print(repr(v))  # Vector(3, 4)
print(str(v))   # (3, 4)
print(v)        # (3, 4) — print() calls __str__

If you only define one, define __repr__. Python falls back to it when __str__ isn’t defined.

Operator Overloading: __add__, __sub__, __mul__

This is where dunders shine — making your classes work with +, -, *, etc.:

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 __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)

Comparison: __eq__, __lt__, and @total_ordering

class Vector:
    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 __lt__(self, other):
        return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)

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

a = Vector(1, 2)
b = Vector(3, 4)

print(a == b)    # False
print(a < b)     # True (magnitude 2.24 < 5.0)

Define __eq__ and __lt__, then use functools.total_ordering to get <=, >, >= for free.

Container Protocol: __len__, __getitem__, __contains__

Make your class behave like a list or dict:

class Deck:
    def __init__(self):
        suits = ['Hearts', 'Diamonds', 'Clubs', 'Spades']
        ranks = ['2', '3', '4', '5', '6', '7', '8', '9',
                 '10', 'J', 'Q', 'K', 'A']
        self.cards = [f"{r} of {s}" for s in suits for r in ranks]

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

    def __getitem__(self, index):
        return self.cards[index]

    def __contains__(self, card):
        return card in self.cards

deck = Deck()
print(len(deck))              # 52
print(deck[0])                # 2 of Hearts
print("A of Spades" in deck)  # True

# __getitem__ also enables iteration and slicing
for card in deck[:3]:
    print(card)
# 2 of Hearts
# 3 of Hearts
# 4 of Hearts

Context Manager: __enter__ and __exit__

These dunders power 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

with Timer():
    total = sum(range(1_000_000))
# Output: Elapsed: 0.0234s

Callable Objects: __call__

Make instances callable like functions:

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor

double = Multiplier(2)
triple = Multiplier(3)

print(double(5))   # 10
print(triple(5))   # 15

This is useful for creating configurable function-like objects, and it’s how many Python frameworks implement decorators and middleware.

Why Dunders Matter

Dunder methods are what make Python feel like Python. They let your custom classes participate in the language’s syntax — operators, for loops, with statements, len(), in, subscripting — instead of requiring users to call custom method names. A class with well-implemented dunders is a class that feels native.


See also: