🔵 Intermediate

Magic Methods (Dunder)

📖 Lesson 22 ⏱ 40 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Understand what dunder (magic) methods are and how Python calls them
  • Implement string representation with __repr__ and __str__
  • Support arithmetic and comparison operators
  • Make objects iterable, sized, and subscriptable
  • Implement callable objects with __call__
  • Create context managers with __enter__ and __exit__
  • Control attribute access with __getattr__ and __setattr__

What are Dunder Methods?

Dunder (double underscore) methods — also called magic methods or special methods — are the hooks Python calls behind the scenes when you use operators, built-in functions, or language syntax on your objects.

When you write a + b, Python calls a.__add__(b). When you write len(obj), Python calls obj.__len__(). Implementing these methods makes your custom objects feel like native Python types.

class Bag:
    def __init__(self, items):
        self._items = list(items)

    def __len__(self):          # len(bag)
        return len(self._items)

    def __contains__(self, item):  # item in bag
        return item in self._items

    def __repr__(self):         # repr(bag) / REPL display
        return f"Bag({self._items!r})"

b = Bag(["apple", "banana", "cherry"])
print(len(b))              # 3
print("apple" in b)        # True
print("grape"  in b)       # False
print(b)                   # Bag(['apple', 'banana', 'cherry'])
intro.py
You never call dunder methods directly (don't write obj.__len__()). You implement them so Python's operators and built-ins can call them on your behalf. This is the data model — the backbone of Python's design.

String Representation

class Card:
    SUITS  = {"S": "♠", "H": "♥", "D": "♦", "C": "♣"}
    RANKS  = {1: "A", 11: "J", 12: "Q", 13: "K"}

    def __init__(self, rank, suit):
        self.rank = rank
        self.suit = suit

    def __repr__(self):
        """Unambiguous — should ideally reconstruct the object."""
        return f"Card({self.rank!r}, {self.suit!r})"

    def __str__(self):
        """Human-readable — shown by print()."""
        rank_str = self.RANKS.get(self.rank, str(self.rank))
        suit_str = self.SUITS.get(self.suit, self.suit)
        return f"{rank_str}{suit_str}"

ace = Card(1, "S")
print(repr(ace))   # Card(1, 'S')   — __repr__
print(str(ace))    # A♠             — __str__
print(ace)         # A♠             — print() uses __str__

# In a list, Python uses __repr__ for each item
hand = [Card(1,"S"), Card(13,"H"), Card(10,"D")]
print(hand)
# [Card(1, 'S'), Card(13, 'H'), Card(10, 'D')]
str_repr.py
Rule of thumb: __repr__ is for developers (use in logging, REPL, debugging); __str__ is for end users (use in UI, print statements). If only __repr__ is defined, it is used as fallback for __str__. Always define __repr__ — it makes debugging vastly easier.

Comparison Operators

from functools import total_ordering

@total_ordering   # auto-generates missing comparisons from __eq__ and ONE of __lt__/__gt__/__le__/__ge__
class Version:
    """Semantic version number (major.minor.patch)."""

    def __init__(self, major, minor=0, patch=0):
        self.major = major
        self.minor = minor
        self.patch = patch

    def _tuple(self):
        return (self.major, self.minor, self.patch)

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

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

    def __repr__(self):
        return f"Version({self.major}, {self.minor}, {self.patch})"

    def __str__(self):
        return f"{self.major}.{self.minor}.{self.patch}"

v1 = Version(1, 2, 3)
v2 = Version(1, 10, 0)
v3 = Version(1, 2, 3)

print(v1 < v2)    # True
print(v1 > v2)    # False  — provided by @total_ordering
print(v1 == v3)   # True
print(v1 <= v3)   # True   — provided by @total_ordering
print(sorted([v2, v1, v3]))
# [Version(1, 2, 3), Version(1, 2, 3), Version(1, 10, 0)]
comparison.py
OperatorDunderReflected
==__eq____eq__
!=__ne____ne__
<__lt____gt__
<=__le____ge__
>__gt____lt__
>=__ge____le__
Return NotImplemented (not False) when a comparison is not supported for the given type. This signals Python to try the reflected method on the other operand before giving up.

Arithmetic Operators

class Money:
    """Immutable money value with currency."""

    def __init__(self, amount, currency="GBP"):
        self.amount   = round(float(amount), 2)
        self.currency = currency

    def _check_currency(self, other):
        if self.currency != other.currency:
            raise ValueError(
                f"Cannot operate on {self.currency} and {other.currency}"
            )

    # ── Binary operators ──
    def __add__(self, other):
        self._check_currency(other)
        return Money(self.amount + other.amount, self.currency)

    def __sub__(self, other):
        self._check_currency(other)
        return Money(self.amount - other.amount, self.currency)

    def __mul__(self, factor):          # Money * scalar
        return Money(self.amount * factor, self.currency)

    def __rmul__(self, factor):         # scalar * Money
        return self.__mul__(factor)

    def __truediv__(self, divisor):     # Money / scalar
        return Money(self.amount / divisor, self.currency)

    # ── Unary operators ──
    def __neg__(self):                  # -money
        return Money(-self.amount, self.currency)

    def __abs__(self):                  # abs(money)
        return Money(abs(self.amount), self.currency)

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

    def __str__(self):
        symbol = {"GBP": "£", "USD": "$", "EUR": "€"}.get(self.currency, self.currency)
        return f"{symbol}{self.amount:,.2f}"

a = Money(10.50)
b = Money(3.25)
print(a + b)    # £13.75
print(a - b)    # £7.25
print(a * 2)    # £21.00
print(2 * a)    # £21.00
print(-a)       # £-10.50
print(abs(-a))  # £10.50
arithmetic.py
OperatorDunderReflected (right-hand)In-place
+__add____radd____iadd__
-__sub____rsub____isub__
*__mul____rmul____imul__
/__truediv____rtruediv____itruediv__
//__floordiv____rfloordiv____ifloordiv__
%__mod____rmod____imod__
**__pow____rpow____ipow__
-x__neg__
abs(x)__abs__

Container Protocol

Make your objects behave like sequences, mappings, or sets by implementing the container dunders:

class SortedList:
    """A list that stays sorted at all times."""

    def __init__(self, items=None):
        self._data = sorted(items or [])

    def add(self, item):
        import bisect
        bisect.insort(self._data, item)

    # ── Sequence protocol ──
    def __len__(self):              # len(sl)
        return len(self._data)

    def __getitem__(self, index):   # sl[i], sl[1:3], for x in sl
        return self._data[index]

    def __contains__(self, item):   # item in sl
        import bisect
        i = bisect.bisect_left(self._data, item)
        return i < len(self._data) and self._data[i] == item

    def __iter__(self):             # for x in sl
        return iter(self._data)

    def __reversed__(self):         # reversed(sl)
        return reversed(self._data)

    def __repr__(self):
        return f"SortedList({self._data!r})"

sl = SortedList([5, 1, 3])
sl.add(2)
sl.add(4)
print(sl)            # SortedList([1, 2, 3, 4, 5])
print(len(sl))       # 5
print(sl[0])         # 1
print(sl[-1])        # 5
print(sl[1:3])       # [2, 3]
print(3 in sl)       # True
print(list(reversed(sl)))  # [5, 4, 3, 2, 1]

for item in sl:
    print(item, end=" ")  # 1 2 3 4 5
container.py
Implementing __getitem__ alone (without __iter__) is enough for a for loop to work — Python will call obj[0], obj[1], … until IndexError. But defining __iter__ explicitly is faster and clearer.

Callable Objects — __call__

Any object with a __call__ method can be invoked like a function. This is useful for objects that need to maintain state between calls:

class Multiplier:
    """A callable that multiplies its input by a fixed factor."""

    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor

    def __repr__(self):
        return f"Multiplier({self.factor})"

double = Multiplier(2)
triple = Multiplier(3)

print(double(5))    # 10
print(triple(5))    # 15
print(callable(double))  # True

# Useful for stateful transformers and pipelines
class RateLimiter:
    """Allows at most `limit` calls per session."""
    def __init__(self, limit):
        self.limit = limit
        self._calls = 0

    def __call__(self, func, *args, **kwargs):
        if self._calls >= self.limit:
            raise RuntimeError(f"Rate limit of {self.limit} calls exceeded")
        self._calls += 1
        return func(*args, **kwargs)

limiter = RateLimiter(3)
for i in range(3):
    print(limiter(str.upper, "hello"))   # HELLO × 3

try:
    limiter(str.upper, "hello")   # 4th call
except RuntimeError as e:
    print(e)
callable.py

Context Managers — __enter__ & __exit__

Implement __enter__ and __exit__ to make your objects work with with statements — guaranteeing setup and teardown:

import time

class Timer:
    """Context manager that measures elapsed time."""

    def __enter__(self):
        self._start = time.perf_counter()
        return self   # value bound to 'as' variable

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self._start
        print(f"Elapsed: {self.elapsed:.4f}s")
        return False  # False = don't suppress exceptions

with Timer() as t:
    total = sum(range(1_000_000))

print(f"Sum: {total}, Time: {t.elapsed:.4f}s")
timer_cm.py
class ManagedDatabase:
    """Simulates a database connection with guaranteed cleanup."""

    def __init__(self, url):
        self.url  = url
        self._conn = None

    def __enter__(self):
        print(f"Connecting to {self.url}")
        self._conn = {"url": self.url, "open": True}  # simulate connection
        return self._conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Closing connection")
        if self._conn:
            self._conn["open"] = False
        if exc_type is not None:
            print(f"Exception occurred: {exc_val}")
        return False   # propagate exceptions

with ManagedDatabase("postgres://localhost/mydb") as conn:
    print(f"Connected: {conn}")
    # ... do database work ...
# Closing connection (always happens)
managed_db.py
__exit__ receives three arguments: the exception type, value, and traceback (all None if no exception occurred). Return True to suppress the exception; return False (or None) to let it propagate.

Attribute Access

These dunders let you intercept and customise attribute getting and setting:

class AttrLogger:
    """Log all attribute accesses and mutations."""

    def __init__(self, **kwargs):
        # Use object.__setattr__ to bypass our own __setattr__ during init
        object.__setattr__(self, "_data", {})
        for k, v in kwargs.items():
            self._data[k] = v

    def __getattr__(self, name):
        # Called only when normal lookup fails
        if name in self._data:
            print(f"GET {name}")
            return self._data[name]
        raise AttributeError(f"No attribute {name!r}")

    def __setattr__(self, name, value):
        # Called on EVERY attribute assignment
        print(f"SET {name} = {value!r}")
        self._data[name] = value

    def __delattr__(self, name):
        print(f"DEL {name}")
        del self._data[name]

obj = AttrLogger(x=1, y=2)
obj.z = 3           # SET z = 3
print(obj.x)        # GET x → 1
del obj.z           # DEL z
attr_access.py
__getattr__ vs __getattribute__: __getattr__ is only called when normal attribute lookup fails (safe to override). __getattribute__ is called on every attribute access — very easy to cause infinite recursion. Almost always use __getattr__.

__slots__ — memory-efficient objects

class Point:
    """Uses __slots__ to prevent arbitrary attribute creation
    and reduce per-instance memory by ~40%."""
    __slots__ = ("x", "y")

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

p = Point(1, 2)
print(p.x)    # 1

# p.z = 3    # AttributeError — only x and y are allowed
# print(p.__dict__)  # AttributeError — no __dict__ with __slots__
slots.py

Numeric Conversions

class Fraction:
    def __init__(self, numerator, denominator):
        from math import gcd
        g = gcd(abs(numerator), abs(denominator))
        self.num = numerator   // g
        self.den = denominator // g

    # ── Numeric conversion dunders ──
    def __int__(self):       # int(fraction)
        return self.num // self.den

    def __float__(self):     # float(fraction)
        return self.num / self.den

    def __bool__(self):      # bool(fraction), truthiness
        return self.num != 0

    def __round__(self, n=0):  # round(fraction, n)
        return round(float(self), n)

    def __repr__(self):
        return f"Fraction({self.num}, {self.den})"

    def __str__(self):
        return f"{self.num}/{self.den}"

f = Fraction(3, 4)
print(float(f))     # 0.75
print(int(f))       # 0
print(bool(f))      # True
print(bool(Fraction(0, 5)))   # False
print(round(f, 1))  # 0.8
numeric.py

Quick Reference

CategoryDunderTriggered by
Representation__repr__repr(x), REPL, !r format
__str__str(x), print(x)
__format__format(x, spec), f-string {x:spec}
Container__len__len(x)
__getitem__x[key]
__setitem__x[key] = val
__contains__item in x
Iteration__iter__for item in x, iter(x)
__next__next(x)
Callable__call__x(...)
Context mgr__enter__with x as y
__exit__end of with block
Attribute__getattr__x.name (when not found)
__setattr__x.name = val
__delattr__del x.name
Numeric__int__ / __float__int(x) / float(x)
__bool__bool(x), truthiness tests
__hash__hash(x), dict key, set member
Lifecycle__init__ / __del__Construction / garbage collection
🤖

Ask your AI tutor! Not sure which dunder to implement for a specific behaviour? Confused about when to return NotImplemented? Want to see how Python's built-in list or dict would look if written in pure Python? Great things to explore.

💻 Exercises

01 Fraction Class

Build a Fraction class that supports:

  • Construction with automatic simplification (use math.gcd)
  • +, -, *, / between fractions
  • ==, <, > (use @total_ordering)
  • float(f), int(f), bool(f)
  • Readable __str__ ("3/4") and __repr__
Show solution
from math import gcd
from functools import total_ordering

@total_ordering
class Fraction:
    def __init__(self, num, den=1):
        if den == 0:
            raise ZeroDivisionError("Fraction denominator cannot be zero")
        sign = -1 if (num * den < 0) else 1
        g = gcd(abs(num), abs(den))
        self.num = sign * abs(num) // g
        self.den = abs(den) // g

    def __repr__(self):
        return f"Fraction({self.num}, {self.den})"

    def __str__(self):
        return f"{self.num}" if self.den == 1 else f"{self.num}/{self.den}"

    def __eq__(self, other):
        if isinstance(other, int):
            other = Fraction(other)
        if not isinstance(other, Fraction):
            return NotImplemented
        return self.num == other.num and self.den == other.den

    def __lt__(self, other):
        if isinstance(other, int):
            other = Fraction(other)
        if not isinstance(other, Fraction):
            return NotImplemented
        return self.num * other.den < other.num * self.den

    def __add__(self, other):
        if isinstance(other, int): other = Fraction(other)
        return Fraction(self.num * other.den + other.num * self.den, self.den * other.den)

    def __sub__(self, other):
        if isinstance(other, int): other = Fraction(other)
        return Fraction(self.num * other.den - other.num * self.den, self.den * other.den)

    def __mul__(self, other):
        if isinstance(other, int): other = Fraction(other)
        return Fraction(self.num * other.num, self.den * other.den)

    def __truediv__(self, other):
        if isinstance(other, int): other = Fraction(other)
        return Fraction(self.num * other.den, self.den * other.num)

    def __float__(self): return self.num / self.den
    def __int__(self):   return self.num // self.den
    def __bool__(self):  return self.num != 0

a = Fraction(1, 2)
b = Fraction(1, 3)
print(a + b)    # 5/6
print(a - b)    # 1/6
print(a * b)    # 1/6
print(a / b)    # 3/2
print(a > b)    # True
print(float(a)) # 0.5
print(sorted([b, a, Fraction(1,4)]))  # [1/4, 1/3, 1/2]
02 Custom Mapping

Build a FrozenDict — an immutable dictionary that:

  • Accepts items at construction time only
  • Supports d[key], key in d, len(d), for key in d
  • Raises TypeError on any attempt to set or delete items
  • Is hashable — implements __hash__ (hash of a frozenset of items)
  • Implements __repr__ and __eq__
Show solution
class FrozenDict:
    """An immutable, hashable dictionary."""

    def __init__(self, *args, **kwargs):
        self._data = dict(*args, **kwargs)

    def __getitem__(self, key):
        return self._data[key]

    def __setitem__(self, key, value):
        raise TypeError("FrozenDict does not support item assignment")

    def __delitem__(self, key):
        raise TypeError("FrozenDict does not support item deletion")

    def __contains__(self, key):
        return key in self._data

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

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

    def __eq__(self, other):
        if isinstance(other, FrozenDict):
            return self._data == other._data
        if isinstance(other, dict):
            return self._data == other
        return NotImplemented

    def __hash__(self):
        return hash(frozenset(self._data.items()))

    def __repr__(self):
        return f"FrozenDict({self._data!r})"

fd = FrozenDict({"a": 1, "b": 2, "c": 3})
print(fd["a"])       # 1
print("b" in fd)     # True
print(len(fd))       # 3
print(list(fd))      # ['a', 'b', 'c']
print(hash(fd))      # some integer — it's hashable!

# Use as dict key or in a set
lookup = {fd: "found it"}
print(lookup[fd])    # found it

try:
    fd["d"] = 4
except TypeError as e:
    print(e)  # FrozenDict does not support item assignment
03 Pipeline Builder

Build a Pipeline class that chains callable steps together. It should support:

  • pipe | func — add a step using __or__
  • pipe(value) — execute all steps via __call__
  • len(pipe) — number of steps
  • repr(pipe) — show the function names in order
pipeline = Pipeline() | str.strip | str.upper | str.split
print(pipeline("  hello world  "))
# ['HELLO', 'WORLD']
Show solution
class Pipeline:
    def __init__(self, *steps):
        self._steps = list(steps)

    def __or__(self, func):
        """Add a step: pipeline | func."""
        return Pipeline(*self._steps, func)

    def __call__(self, value):
        """Execute the pipeline."""
        for step in self._steps:
            value = step(value)
        return value

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

    def __repr__(self):
        names = [getattr(s, "__name__", repr(s)) for s in self._steps]
        return f"Pipeline({' | '.join(names)})"

# Test
pipeline = Pipeline() | str.strip | str.upper | str.split
print(pipeline("  hello world  "))   # ['HELLO', 'WORLD']
print(len(pipeline))                 # 3
print(repr(pipeline))                # Pipeline(strip | upper | split)

# Composing pipelines
import math
math_pipe = Pipeline() | (lambda x: x ** 2) | math.sqrt | round
print(math_pipe(5))    # 5  (sqrt(25) = 5.0 → round → 5)

# Reusable
clean = Pipeline() | str.strip | str.lower
print(clean("  Alice  "))  # alice