Dunder / Magic Methods Interview Questions & Answers
7 questions Updated 2026-06-18
Python interview questions on dunder/magic methods — __repr__/__str__, __eq__ and __hash__, operator overloading with __add__, the sequence protocol (__len__/__getitem__), __call__, and functools.total_ordering.
Read the in-depth guidePython Dunder Methods Explained — Operator Overloading and the Data ModelDunder methods ("double underscore", e.g. __init__, __len__) are special
hooks Python calls implicitly to make your objects work with built-in syntax
and functions. They are how Python implements operator overloading and
protocols — len(x) calls x.__len__(), x + y calls x.__add__(y),
x[0] calls x.__getitem__(0).
class Box:
def __init__(self, items):
self.items = items
def __len__(self):
return len(self.items) # makes len(box) work
b = Box([1, 2, 3])
len(b) # 3 — Python calls b.__len__()
They're sometimes called "magic methods" but there's no magic — they're a documented protocol. Implementing the right dunders makes your objects feel like native Python types.
__repr__ defines the unambiguous developer representation (REPL output,
containers, repr()); __str__ defines the human-readable form (print(),
str()). When __str__ is absent, Python falls back to __repr__ — so
__repr__ is the one to always implement.
class Temperature:
def __init__(self, c):
self.c = c
def __repr__(self):
return f"Temperature({self.c})" # eval-friendly
def __str__(self):
return f"{self.c}°C" # friendly
t = Temperature(20)
str(t) # '20°C'
repr(t) # 'Temperature(20)'
A good __repr__ ideally lets eval(repr(obj)) reconstruct the object. Always
define __repr__; add __str__ only when a separate user-facing string helps.
__eq__ defines value equality (==); __hash__ returns the integer used
by dicts and sets. They have a contract: if a == b then
hash(a) == hash(b). Defining __eq__ alone sets __hash__ = None, making
instances unhashable — so define both, derived from the same immutable
fields.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y)) # same fields as __eq__
{Point(1, 2), Point(1, 2)} # one element — treated as equal
Only hash on fields that never change after creation. If you break the contract, objects you store in a set/dict become unfindable.
Implement the matching arithmetic dunder: __add__ for +, __sub__ for
-, __mul__ for *, __lt__ for <, and so on. Python calls them when the
operator is used. Return a new object (or NotImplemented if you can't
handle the other operand, so Python can try the reflected method like
__radd__).
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y) # v1 + v2
def __mul__(self, k):
return Vector(self.x * k, self.y * k) # v * 3
def __repr__(self):
return f"Vector({self.x}, {self.y})"
Vector(1, 2) + Vector(3, 4) # Vector(4, 6)
Vector(1, 2) * 3 # Vector(3, 6)
Return NotImplemented (not raise) for unsupported types so Python can fall
back gracefully. Don't overload operators in surprising ways — keep semantics
intuitive.
Implementing __len__ makes len(obj) work, and __getitem__ makes
indexing, slicing, and iteration work — Python can iterate by calling
__getitem__(0), __getitem__(1), ... until IndexError, even without an
__iter__. Together they form the sequence protocol.
class Playlist:
def __init__(self, songs):
self.songs = songs
def __len__(self):
return len(self.songs) # len(pl)
def __getitem__(self, i):
return self.songs[i] # pl[0], pl[1:3], and iteration
pl = Playlist(["a", "b", "c"])
len(pl) # 3
pl[1] # 'b'
for s in pl: # iterates via __getitem__
print(s)
Add __contains__ for in and __setitem__ for assignment. This duck-typed
protocol is why custom containers feel like lists.
__call__ makes an instance itself callable like a function — obj()
invokes obj.__call__(). This lets objects carry state between calls, which
a plain function can't do as cleanly. It's the basis of function objects and
many decorators.
class Multiplier:
def __init__(self, factor):
self.factor = factor # remembered state
def __call__(self, x):
return x * self.factor # obj(x)
double = Multiplier(2)
double(5) # 10 — calling the instance
callable(double) # True
Use it for stateful callables — configurable functions, accumulators, or class-based decorators. If you just need behavior without state, a closure or plain function is simpler.
@functools.total_ordering is a class decorator that fills in the missing
comparison operators from the ones you define. You provide __eq__ plus one
of __lt__, __le__, __gt__, __ge__, and it generates the rest — saving you
from writing all six.
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, n):
self.n = n
def __eq__(self, other):
return self.n == other.n
def __lt__(self, other):
return self.n < other.n # only this + __eq__ needed
Version(1) < Version(2) # True
Version(2) >= Version(1) # True — generated by total_ordering
sorted([Version(3), Version(1), Version(2)]) # works
The trade-off is a small performance cost from derived comparisons; for hot-path code, define all operators explicitly. Otherwise it's a clean way to get full ordering with minimal boilerplate.
Practice tests are coming soon
Get notified when interactive mock interviews and quizzes launch.