Skip to content
elephantoo

Magic (dunder) methods

Lesson 21 of 38 14 min read

Make objects work with ==, <, +, len(), indexing, iteration, calls and with statements.


Why does len() work on lists, strings and dicts? Why can you add numbers and also add lists? Python's secret is special methods — also called magic or dunder methods because they're surrounded by double underscores, like __len__ and __add__. When you write len(x), Python calls x.__len__(); when you write a + b, it calls a.__add__(b). Implement these methods in your own classes and your objects behave like built-ins.

You never call dunder methods directly in normal code — you write len(x), not x.__len__(). You define them so Python's syntax works on your objects.

String representations: __repr__ and __str__#

You met these in the classes lesson. As a rule, always define __repr__; add __str__ only if you want a different, friendlier version for end users.

Python
class Money:
    def __init__(self, amount, currency="INR"):
        self.amount = amount
        self.currency = currency

    def __repr__(self):
        return f"Money({self.amount!r}, {self.currency!r})"

    def __str__(self):
        return f"{self.amount:,.2f} {self.currency}"


m = Money(1499.5)
print(m)
print(repr(m))
print(f"{m!r} / {m}")
Output
1,499.50 INR
Money(1499.5, 'INR')
Money(1499.5, 'INR') / 1,499.50 INR

Equality and ordering#

By default, == compares identity — two separate objects are never equal, even with identical data. Define __eq__ to compare by value, and the ordering methods (__lt__, __le__, __gt__, __ge__) to support <, sorted(), min() and max():

Python
from functools import total_ordering


@total_ordering                      # fills in <=, >, >= from __eq__ and __lt__
class Version:
    def __init__(self, text):
        self.parts = tuple(int(p) for p in text.split("."))

    def __repr__(self):
        return "Version(" + repr(".".join(map(str, self.parts))) + ")"

    def __eq__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return self.parts == other.parts

    def __lt__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return self.parts < other.parts

    def __hash__(self):              # equal objects must hash equally
        return hash(self.parts)


versions = [Version("3.12.1"), Version("3.9.18"), Version("3.13.0")]
print(sorted(versions))
print(Version("3.12") == Version("3.12"), Version("3.9") < Version("3.10"))
print(max(versions), Version("3.12.1") >= Version("3.12.0"))
print(len({Version("1.0"), Version("1.0")}))
Output
[Version('3.9.18'), Version('3.12.1'), Version('3.13.0')]
True True
Version('3.13.0') True
1

Notice Version("3.9") < Version("3.10") is True — comparing tuples of ints gets this right, whereas comparing the strings "3.9" < "3.10" would be wrong.

Two important rules:

  • Return NotImplemented (not False, and not raising) when you don't recognise the other type. Python then tries the other side, and finally falls back to a sensible result or TypeError.
  • Defining __eq__ sets __hash__ to None, making instances unhashable. If your objects are immutable and you need them in sets or as dict keys, define __hash__ based on the same fields as __eq__.

Arithmetic operators#

ExpressionMethodReflected
a + b__add____radd__
a - b__sub____rsub__
a * b__mul____rmul__
a / b__truediv____rtruediv__
a += b__iadd__ (falls back to __add__)
-a, abs(a)__neg__, __abs__
Python
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):
        return f"Vector({self.x}, {self.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):
        if not isinstance(scalar, (int, float)):
            return NotImplemented
        return Vector(self.x * scalar, self.y * scalar)

    __rmul__ = __mul__              # so 3 * v works as well as v * 3

    def __neg__(self):
        return Vector(-self.x, -self.y)

    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __bool__(self):
        return bool(self.x or self.y)


v = Vector(3, 4)
w = Vector(1, -1)
print(v + w, v * 2, 3 * v, -v)
print(abs(v), bool(Vector(0, 0)))
try:
    v + 5
except TypeError as e:
    print("TypeError:", e)
Output
Vector(4, 3) Vector(6, 8) Vector(9, 12) Vector(-3, -4)
5.0 False
TypeError: unsupported operand type(s) for +: 'Vector' and 'int'

When Python evaluates 3 * v, it first tries int.__mul__(3, v), which returns NotImplemented; it then tries the reflected v.__rmul__(3). Because we returned NotImplemented for v + 5, Python produced a clear TypeError for us.

Making containers: __len__, __getitem__, __contains__, __iter__#

Implement the sequence protocol and your class works with len(), indexing, slicing, in and for:

Python
class Playlist:
    def __init__(self, name, songs=None):
        self.name = name
        self._songs = list(songs or [])

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

    def __getitem__(self, index):          # supports ints AND slices
        result = self._songs[index]
        if isinstance(index, slice):
            return Playlist(f"{self.name} (part)", result)
        return result

    def __contains__(self, song):
        return song.lower() in (s.lower() for s in self._songs)

    def __iter__(self):
        return iter(self._songs)

    def __repr__(self):
        return f"Playlist({self.name!r}, {self._songs!r})"


p = Playlist("Focus", ["Weightless", "Clair de Lune", "Experience", "Nuvole Bianche"])
print(len(p), p[0], p[-1])
print(p[1:3])
print("experience" in p)
for i, song in enumerate(p, 1):
    print(i, song)
Output
4 Weightless Nuvole Bianche
Playlist('Focus (part)', ['Clair de Lune', 'Experience'])
True
1 Weightless
2 Clair de Lune
3 Experience
4 Nuvole Bianche

For mappings, add __setitem__ and __delitem__ too. If you only need __iter__, Python can use it for in checks as a fallback.

Callable objects: __call__#

An object with __call__ can be called like a function — useful for functions that need to remember configuration or state:

Python
class Multiplier:
    def __init__(self, factor):
        self.factor = factor
        self.calls = 0

    def __call__(self, value):
        self.calls += 1
        return value * self.factor


triple = Multiplier(3)
print(triple(5), triple(10))
print(list(map(triple, [1, 2])), triple.calls)
Output
15 30
[3, 6] 4

Context managers: __enter__ and __exit__#

The with statement calls __enter__ on entry and __exit__ on the way out — even if an exception occurred. __exit__ receives the exception details (or three Nones):

Python
import time


class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self                       # bound to the name after `as`

    def __exit__(self, exc_type, exc, tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"took {self.elapsed:.1f}s", "(with error)" if exc_type else "")
        return False                      # False: don't suppress exceptions


with Timer() as t:
    time.sleep(0.3)

try:
    with Timer():
        raise ValueError("boom")
except ValueError:
    print("error still propagated")
Output
took 0.3s 
took 0.0s (with error)
error still propagated

Other dunders worth knowing#

  • __format__ — custom format specs in f-strings.
  • __getattr__ — called when normal attribute lookup fails (handy for proxies).
  • __hash__ — hashing for sets/dict keys.
  • __init_subclass__, __class_getitem__ — advanced class customisation.
  • __slots__ (an attribute, not a method) — saves memory by fixing the set of attributes.

Common mistakes#

  • Returning False or raising in __eq__ for unknown types — return NotImplemented.
  • Defining __eq__ and then using objects in sets — remember __hash__.
  • Mutating self in __add__ — operators should return a new object; leave in-place changes to __iadd__.
  • Calling dunders directly — write len(x), a + b, str(x).
  • Writing lots of boilerplate (__init__, __repr__, __eq__) by hand for data classes — the dataclasses lesson shows how to generate it.

What's next#

Our Thermostat earlier needed a set_target() method for validation. Next you'll see the Pythonic alternative — properties — along with class methods and static methods.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Which method makes len(obj) work on your class?

  2. 2.If you define __eq__ but not __hash__, what happens?

  3. 3.What should __add__ return when it doesn't know how to add the other operand's type?

Finished reading?

Mark this lesson complete to track your progress.