Python · Object-Oriented Programming

Python Dunder Methods Explained — Operator Overloading and the Data Model

5 min read Updated 2026-06-19

Practice Dunder / Magic Methods interview questions

Python dunder methods, explained

The reason len(x), x + y, x[0], for i in x, and print(x) all "just work" on your own classes is Python's data model: a set of specially named dunder methods (double-underscore, like __len__) that the interpreter calls behind the scenes. Master these and your objects integrate seamlessly with the language's syntax. This guide covers the dunders interviewers ask about most.

What dunder methods are

Dunder ("double underscore") methods — also called magic methods — are hooks Python calls for built-in operations. You rarely call them directly; you trigger them through syntax. len(x) calls x.__len__(), x + y calls x.__add__(y), x[k] calls x.__getitem__(k).

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __add__(self, other):           # called by the + operator
        return Vector(self.x + other.x, self.y + other.y)

Vector(1, 2) + Vector(3, 4)             # Python calls __add__ for you

The idea is protocols, not inheritance: anything that implements the right dunders behaves like a built-in, no base class required.

repr vs str

Both produce a string, but for different audiences. __repr__ is the unambiguous, developer-facing representation (ideally something that could recreate the object); __str__ is the readable, user-facing one. str()/print use __str__ and fall back to __repr__ if it's missing.

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})"   # for developers / the REPL
    def __str__(self):
        return f"({self.x}, {self.y})"            # for end users

p = Point(1, 2)
repr(p)   # 'Point(x=1, y=2)'
str(p)    # '(1, 2)'

Always define __repr__ — it's what you see in the debugger, in logs, and inside containers. __str__ is optional and only worth adding when the user-facing form differs.

eq and hash go together

__eq__ defines value equality (==); __hash__ lets instances live in sets and act as dict keys. They must be consistent: equal objects must have equal hashes. Defining __eq__ alone makes the class unhashable (Python sets __hash__ to None).

class Money:
    def __init__(self, cents):
        self.cents = cents
    def __eq__(self, other):
        return self.cents == other.cents
    def __hash__(self):
        return hash(self.cents)        # consistent with __eq__

{Money(100), Money(100)}               # one element — treated as equal

Hash only on fields that never change after creation; otherwise a mutated key gets lost in its hash bucket.

Operator overloading

Arithmetic and comparison operators map to dunders: +__add__, *__mul__, <__lt__, and so on. Implementing them lets your objects participate in expressions naturally.

class Money:
    def __init__(self, cents): self.cents = cents
    def __add__(self, other):  return Money(self.cents + other.cents)
    def __lt__(self, other):   return self.cents < other.cents
    def __repr__(self):        return f"${self.cents/100:.2f}"

Money(150) + Money(50)   # $2.00
Money(150) < Money(200)  # True

For comparisons, functools.total_ordering fills in the rest from just __eq__ and one ordering method, saving boilerplate.

The sequence/container protocol

Implement __len__ and __getitem__ and your object behaves like a sequence — it supports len(), indexing, slicing, and iteration (Python falls back to __getitem__ with 0, 1, 2… if there's no __iter__), plus in.

class Deck:
    def __init__(self, cards): self._cards = cards
    def __len__(self):              return len(self._cards)
    def __getitem__(self, i):       return self._cards[i]

deck = Deck(["A", "K", "Q"])
len(deck)        # 3
deck[0]          # 'A'
for c in deck:   # iterable for free
    print(c)

This is duck typing at its best: implement the protocol and the object is a sequence, without subclassing list.

call: make instances callable

Defining __call__ lets you call an instance like a function — useful for stateful "function objects," decorators, and configurable callbacks.

class Multiplier:
    def __init__(self, factor): self.factor = factor
    def __call__(self, x):      return x * self.factor

double = Multiplier(2)
double(10)        # 20  — the instance behaves like a function

Context-manager dunders

__enter__ and __exit__ make an object usable in a with block, guaranteeing cleanup. That's the same protocol that powers open() — covered in depth in the context-managers guide.

class Timer:
    def __enter__(self): import time; self.t = time.perf_counter(); return self
    def __exit__(self, *exc): print(f"{time.perf_counter() - self.t:.4f}s")

Recap

Dunder methods are the hooks Python calls for built-in syntax — they're how user classes plug into len(), operators, indexing, iteration, print, and with. Always give a class a __repr__; pair __eq__ with a consistent __hash__ (and hash only on immutable fields); overload operators with __add__/__lt__/etc.; implement __len__+__getitem__ for sequence behaviour; and use __call__ for callable instances. Think protocols, not inheritance — implement the right dunders and your objects feel native.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.