Special / Magic Methods

Dunder methods like __str__, __len__, and __add__ are how a class plugs into Python's built-in syntax and functions -- implement the right ones and your objects work with print(), len(), for, +, [], and more.

Special methods (also called 'dunder' methods, for their double-underscore names) are the hooks Python's built-in operators and functions use to work with objects generically. Implementing __add__ makes + work on your class; implementing __len__ makes len() work; implementing __iter__ makes for work. This topic covers the most commonly used dunders and how they group into related families, not an exhaustive catalog of every one that exists.

__str__ and __repr__: String Representations

__repr__ should return an unambiguous, ideally code-like representation aimed at developers (used by repr(), the interactive interpreter, and as the fallback for print() if __str__ is absent). __str__ should return a readable representation aimed at end users (used by str() and print()). Every class should define __repr__; __str__ is optional and only needed when the developer- and user-facing representations should differ.

A list's own __str__/__repr__ calls repr() on each element, not str() -- this is why __repr__ matters even when __str__ is also defined.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self) -> str:
        return f"Point(x={self.x}, y={self.y})"  # unambiguous, code-like

    def __str__(self) -> str:
        return f"({self.x}, {self.y})"  # friendlier for end users


p = Point(1, 2)
print(p)        # (1, 2) -- uses __str__
print(repr(p))  # Point(x=1, y=2) -- uses __repr__
print([p])       # [Point(x=1, y=2)] -- containers always show repr() of elements

__len__: Making an Object Work With len()

Implementing __len__ (returning a non-negative int) lets len(obj) work, and also makes the object usable in falsy/truthy tests when __bool__ is absent -- Python falls back to '__len__() == 0 means falsy' in that case.

class Playlist:
    def __init__(self, tracks):
        self._tracks = list(tracks)

    def __len__(self) -> int:
        return len(self._tracks)


p = Playlist(["a", "b", "c"])
print(len(p))              # 3
print(bool(Playlist([])))  # False -- falls back to __len__() == 0

__iter__ and __next__: the Iterator Protocol

__iter__ should return an iterator object (one that itself implements __next__); implementing this makes the object usable in for loops, list(), sum(), and anywhere iteration is expected. A common pattern is for a class to be its own iterator by implementing both __iter__ (returning self) and __next__ (advancing and raising StopIteration when exhausted).

For a container (something you'd iterate over more than once), it's usually better for __iter__ to return a fresh iterator each time (e.g. via a generator function) rather than returning self, so the container itself doesn't get 'used up' after one loop.

class Countdown:
    def __init__(self, start: int) -> None:
        self.current = start

    def __iter__(self):
        return self

    def __next__(self) -> int:
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1


for n in Countdown(3):
    print(n)  # 3, 2, 1
print(list(Countdown(3)))  # [3, 2, 1]

__getitem__, __setitem__, __contains__: Container Behavior

__getitem__ enables obj[key] reads; __setitem__ enables obj[key] = value writes; __contains__ enables the in operator. Implementing __getitem__ alone even makes an object iterable as a fallback (Python tries obj[0], obj[1], ... until IndexError), though defining __iter__ explicitly is clearer for anything beyond simple integer-indexed sequences.

class Grid:
    def __init__(self, width: int, height: int) -> None:
        self._data = {}
        self.width, self.height = width, height

    def __getitem__(self, pos: tuple[int, int]):
        return self._data.get(pos, 0)

    def __setitem__(self, pos: tuple[int, int], value) -> None:
        self._data[pos] = value

    def __contains__(self, pos: tuple[int, int]) -> bool:
        return pos in self._data


g = Grid(10, 10)
g[2, 3] = "X"
print(g[2, 3])         # 'X'
print((2, 3) in g)     # True
print((0, 0) in g)     # False

Arithmetic Methods: __add__, __sub__, __mul__, and Friends

Operators like +, -, *, / dispatch to __add__, __sub__, __mul__, __truediv__ and so on. If a op b fails because a's method returns NotImplemented (or doesn't exist), Python tries b's reflected method (__radd__ for +, etc.) before finally raising TypeError. This lets a class support an operation even when it appears on the right-hand side of an operator with an incompatible left operand.

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __rmul__(self, scalar):  # supports scalar * vector too
        return self.__mul__(scalar)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"


v1, v2 = Vector(1, 2), Vector(3, 4)
print(v1 + v2)   # Vector(4, 6)
print(v1 * 3)    # Vector(3, 6) -- __mul__
print(3 * v1)    # Vector(3, 6) -- __rmul__, since int doesn't know how to multiply by Vector

Comparison Methods: __eq__, __lt__, and Friends

__eq__ backs ==, __lt__ backs <, and so on for __le__, __gt__, __ge__, __ne__. By default, objects inherit __eq__ from object, which compares identity (equivalent to is) -- defining __eq__ yourself is what enables meaningful value-based equality. Defining __eq__ also sets the instance's default hashability to None (unhashable) unless __hash__ is defined too, since Python requires that equal objects hash equally, and it can no longer assume that once you've customized equality.

The standard library's functools.total_ordering class decorator can fill in the remaining comparison methods (__le__, __gt__, __ge__) automatically from just __eq__ and one of them (e.g. __lt__), reducing repetition.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

    def __lt__(self, other):
        return (self.x, self.y) < (other.x, other.y)


print(Point(1, 2) == Point(1, 2))  # True
print(Point(1, 2) < Point(1, 3))   # True

Context-Manager Methods: __enter__ and __exit__

__enter__ and __exit__ implement the context-management protocol, letting an object be used with with. __enter__ runs at the start of the block and its return value is bound by as; __exit__ runs when the block ends (normally or via an exception) and can suppress a propagating exception by returning a truthy value.

class ManagedResource:
    def __enter__(self):
        print("acquiring")
        return self

    def __exit__(self, exc_type, exc_value, traceback) -> bool:
        print("releasing")
        return False  # do not suppress exceptions


with ManagedResource() as r:
    print("using resource")
# acquiring
# using resource
# releasing

Lifecycle Methods: __init__, __new__, __del__

__init__ initializes a newly created instance (the one almost every class implements). __new__ is the lower-level method that actually creates the instance object, called before __init__; overriding it is rare and mostly reserved for subclassing immutable built-in types or implementing patterns like singletons. __del__ runs when an instance is about to be destroyed; its exact timing depends on the implementation's memory management (in CPython, typically when the reference count reaches zero -- a CPython implementation detail, not a language guarantee), so it should never be relied on for critical, prompt cleanup -- use a context manager (__enter__/__exit__) for that instead.

class Resource:
    def __init__(self, name):
        self.name = name
        print(f"{name} created")

    def __del__(self):
        print(f"{self.name} destroyed")  # timing not guaranteed by the language


r = Resource("A")
del r  # in CPython, typically triggers __del__ promptly, but this is an implementation detail

Not Every Dunder Is Equally Important

Python defines dozens of special methods, but most classes only ever need a handful: __repr__ almost always; __eq__ and __hash__ when value equality matters; __len__/__iter__/__getitem__ for container-like classes; arithmetic dunders only for genuinely numeric/vector-like types. Implementing dunders your class doesn't semantically need (e.g. __add__ on a class with no meaningful notion of addition) adds surface area without value and can produce confusing behavior. Treat this list as tools to reach for when the semantics genuinely fit, not a checklist to complete.

Practical Usage: Building a Custom Sequence

Combining several dunders turns a class into something that behaves like a built-in sequence, usable with idiomatic Python syntax rather than custom method names.

class Deck:
    def __init__(self, cards):
        self._cards = list(cards)

    def __len__(self):
        return len(self._cards)

    def __getitem__(self, index):
        return self._cards[index]

    def __repr__(self):
        return f"Deck({len(self)} cards)"


d = Deck(["2H", "3H", "4H"])
print(len(d))     # 3
print(d[0])       # '2H'
for card in d:     # works via __getitem__ fallback
    print(card)

Edge Cases

Returning NotImplemented (a singleton, not raising NotImplementedError) from an arithmetic or comparison dunder tells Python to try the other operand's reflected method rather than immediately failing; returning it incorrectly as a boolean-like False from __eq__ instead of a real False can also produce subtly wrong equality results. __hash__ must be consistent with __eq__: two objects that compare equal must return the same hash, or dict/set behavior breaks silently.

class Bad:
    def __eq__(self, other):
        return True  # always equal to everything, including non-Bad objects!
    # no __hash__ override -- but __eq__ existing makes instances unhashable by default

Key Takeaways

Special methods are how a class opts into Python's built-in syntax and functions: print()/str() use __str__ and __repr__, len() uses __len__, for uses __iter__/__next__, [] uses __getitem__/__setitem__, in uses __contains__.

Arithmetic and comparison operators dispatch to dunders (__add__, __eq__, __lt__, ...); returning NotImplemented lets Python fall back to the other operand's reflected method.

__enter__/__exit__ implement the context-manager protocol used by with.

__del__'s timing is implementation-dependent (in CPython, tied to reference counting) and should never be relied on for guaranteed prompt cleanup.

Implement only the dunders your class's semantics genuinely call for -- not every class needs arithmetic or container behavior.

class Point: def __init__(self, x, y): self.x, self.y = x, y # no __repr__ -- print(Point(1,2)) shows <Point object at 0x...> 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})"

Without a __repr__ override, printing or inspecting an instance falls back to object's default, which shows an unhelpful memory address instead of the object's actual data.

class Money: def __add__(self, other): return Money(self.amount + other.amount) # crashes with AttributeError if other lacks.amount class Money: def __add__(self, other): if not isinstance(other, Money): return NotImplemented # lets Python try other.__radd__, or raise a clean TypeError return Money(self.amount + other.amount)

Failing to check the operand type and return NotImplemented for unsupported types produces a confusing AttributeError instead of a clear TypeError, and prevents Python from trying the other operand's reflected method.

class Point: def __eq__(self, other): return self.x == other.x and self.y == other.y # __hash__ is now None -- Point instances silently became unhashable class Point: def __eq__(self, other): return self.x == other.x and self.y == other.y def __hash__(self): return hash((self.x, self.y)) # keep hash consistent with eq

Defining __eq__ without also defining __hash__ makes instances unhashable (Python sets __hash__ to None), which silently breaks using them as dict keys or set elements if hashability was expected.

What is the difference in intent between __str__ and __repr__?

__repr__ should be an unambiguous, developer-facing representation (ideally code-like), used by repr() and as the fallback for print(); __str__ should be a readable, user-facing representation, used by str() and print() when defined.

Why does printing a list of custom objects show each element's __repr__ rather than its __str__, even if both are defined?

Because list's own string conversion calls repr() on each contained element, not str(), so a container's display always reflects its elements' __repr__.

What must an arithmetic dunder method like __add__ return when it doesn't know how to handle the other operand, and why?

It should return the NotImplemented singleton (not raise an error), which tells Python to try the other operand's reflected method (e.g. __radd__) before ultimately raising TypeError if neither operand can handle the operation.

What happens to an object's hashability if you define __eq__ but not __hash__?

Python sets __hash__ to None by default, making the object unhashable, because it can no longer assume the default identity-based hash stays consistent with your custom equality logic -- you must define __hash__ yourself to keep it usable in dicts/sets.

Why can't __del__'s exact execution timing be relied on for critical cleanup, described in general Python-language terms?

Because when an object becomes unreachable and gets collected depends on the implementation's memory management strategy; even in CPython, where reference counting usually triggers __del__ promptly, that's a CPython implementation detail, not a language guarantee -- other implementations or reference cycles can delay it, so a context manager (__enter__/__exit__) is the reliable tool for guaranteed cleanup.

What two methods implement the context-management protocol used by the with statement?

__enter__ (runs at the start of the block, its return value bound by as) and __exit__ (runs when the block ends, and can suppress a propagating exception by returning a truthy value).

Why shouldn't a class implement every possible dunder method it could, even if the code would technically run?

Because dunders should reflect genuine semantics -- implementing __add__ on a class with no real notion of addition, for instance, adds confusing surface area without corresponding value; the goal is matching the right protocol to the right semantics, not maximizing dunder coverage.

Write a Fraction-like class with fields num and den implementing __repr__, __eq__, and __add__ (returning a new Fraction with the mathematically correct sum, unsimplified).

class Fraction: def __init__(self, num: int, den: int) -> None: self.num, self.den = num, den def __repr__(self) -> str: return f"Fraction({self.num}, {self.den})" def __eq__(self, other) -> bool: if not isinstance(other, Fraction): return NotImplemented return self.num * other.den == other.num * self.den def __add__(self, other): if not isinstance(other, Fraction): return NotImplemented return Fraction(self.num * other.den + other.num * self.den, self.den * other.den) f1, f2 = Fraction(1, 2), Fraction(1, 3) print(f1 + f2) # Fraction(5, 6) print(Fraction(1, 2) == Fraction(2, 4)) # True -- cross-multiplication check

__add__ implements fraction addition via cross-multiplication and returns a new Fraction; __eq__ compares by cross-multiplication too so equivalent fractions (1/2 and 2/4) compare equal even with different stored num/den.

What does this print, and which dunder makes it possible without a __bool__ method?

False -- with no __bool__ defined, Python falls back to calling __len__() and treating a result of 0 as falsy; since b._items is empty, len(b) is 0, so bool(b) is False.

Write a ReadOnlyDict-like class wrapping a plain dict, implementing __getitem__, __contains__, __len__, and __iter__ (iterating over keys), with no __setitem__ (so writes fail naturally).

class ReadOnlyDict: def __init__(self, data: dict) -> None: self._data = dict(data) def __getitem__(self, key): return self._data[key] def __contains__(self, key) -> bool: return key in self._data def __len__(self) -> int: return len(self._data) def __iter__(self): return iter(self._data) rd = ReadOnlyDict({"a": 1, "b": 2}) print(rd["a"], len(rd), "b" in rd) for key in rd: print(key) try: rd["c"] = 3 # TypeError -- no __setitem__ defined except TypeError as e: print("caught:", e)

Without __setitem__, attempting rd['c'] = 3 raises TypeError('...' object does not support item assignment) automatically -- the class is read-only simply because the write hook was never implemented, not because of explicit rejection logic.

class Box:
    def __init__(self, items):
        self._items = items
    def __len__(self):
        return len(self._items)

b = Box([])
print(bool(b))