Dunder / Magic Methods Interview Questions & Answers
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.
Dunder 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.
__getattribute__ is called on every attribute access (easy to recurse
infinitely if misused). __getattr__ is the fallback, called only when normal
lookup fails. You almost always want __getattr__.
class Proxy:
def __init__(self, data):
self._data = data
def __getattr__(self, name): # only for missing attrs
return self._data.get(name, f"<no {name}>")
p = Proxy({"x": 1})
p.x # 1 — normal lookup, __getattr__ NOT called
p.missing # '<no missing>' — fallback fires
Rule of thumb: use __getattr__ for lazy/proxy/default attributes; touch
__getattribute__ only for advanced interception (and call super() to avoid
infinite recursion).
__setattr__ intercepts every attribute assignment. Assigning self.x = v
inside it would call __setattr__ again — infinite recursion. Route the actual
write through super().__setattr__ or self.__dict__.
class Validated:
def __setattr__(self, name, value):
if name == "age" and value < 0:
raise ValueError("age must be >= 0")
super().__setattr__(name, value) # the real assignment
v = Validated()
v.age = 5 # ok
v.age = -1 # ValueError
Rule of thumb: in __setattr__, always delegate the final write to
super().__setattr__ (never self.name = ...) to prevent recursion.
__enter__ (runs on entry, its return value is bound by as) and
__exit__ (runs on exit, even on exceptions). __exit__ receives the exception
info and returning True from it suppresses the exception.
class Timer:
def __enter__(self):
import time; self.t = time.time()
return self # bound to `as t`
def __exit__(self, exc_type, exc, tb):
import time; self.elapsed = time.time() - self.t
return False # don't suppress exceptions
with Timer() as t:
do_work()
print(t.elapsed)
Rule of thumb: implement __enter__/__exit__ for setup/teardown pairs; return a
falsy value from __exit__ unless you deliberately want to swallow errors.
__iter__ returns an iterator (often self or a fresh helper); __next__
produces the next value and raises StopIteration when exhausted. An object
with both is an iterator; one with only __iter__ is an iterable.
class Countdown:
def __init__(self, n): self.n = n
def __iter__(self): return self # it's its own iterator
def __next__(self):
if self.n <= 0:
raise StopIteration
self.n -= 1
return self.n + 1
list(Countdown(3)) # [3, 2, 1]
Rule of thumb: __iter__ gives you something to iterate; __next__ advances it.
For reusable iteration, make __iter__ return a new iterator each time.
if obj: calls __bool__ if defined; otherwise it falls back to __len__
(zero = falsy). With neither, the object is always truthy. __bool__ takes priority.
class Cart:
def __init__(self, items): self.items = items
def __len__(self):
return len(self.items) # truthiness from length
bool(Cart([])) # False
bool(Cart([1])) # True
if Cart([1, 2]): # True
...
Rule of thumb: define __len__ for containers (truthiness comes free) or __bool__
for non-container objects with a notion of "empty/disabled."
When a + b and a.__add__(b) returns NotImplemented (or a lacks __add__),
Python tries b.__radd__(a). This lets your type work on the right side of
an operator with types it doesn't control (e.g. 3 * vector).
class Money:
def __init__(self, amt): self.amt = amt
def __add__(self, other):
return Money(self.amt + other.amt)
def __radd__(self, other): # handles sum() starting from 0
return self if other == 0 else Money(self.amt + other)
sum([Money(5), Money(10)]) # uses __radd__ for the initial 0 + Money
Rule of thumb: implement __radd__/__rmul__ etc. so your type composes with
built-ins and sum(); return NotImplemented for truly unsupported operands.
__int__ backs int(obj) (lossy conversions allowed, e.g. floats). __index__
backs lossless integer use — slicing, bin(), hex(), range() — and signals
"this is an integer." Define __index__ for true integer-like types.
class Hours:
def __init__(self, n): self.n = n
def __index__(self):
return self.n # enables list[Hours(2)] and range/bin/hex
"abcdef"[Hours(2)] # 'c' — slicing uses __index__
bin(Hours(5)) # '0b101'
Rule of thumb: implement __index__ (not just __int__) when your type should be
usable as an index or in bit/format operations without lossy conversion.
x in obj calls __contains__(x) if defined. Without it, Python falls back to
iterating (__iter__) or the sequence protocol (__getitem__). Defining it lets you
provide fast or custom membership logic.
class Range2D:
def __init__(self, w, h): self.w, self.h = w, h
def __contains__(self, point):
x, y = point
return 0 <= x < self.w and 0 <= y < self.h
grid = Range2D(3, 3)
(1, 2) in grid # True
(5, 0) in grid # False
Rule of thumb: add __contains__ for O(1)/custom membership; otherwise in works
but does a linear scan via iteration.
More Object-Oriented Programming interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.