Skip to content

Dunder / Magic Methods Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

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 Model(opens in new tab)
15 of 15

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 ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel