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 Model

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 protocolslen(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.