Python - Magic/Dunder Methods (__str__, __repr__, etc.)

Magic methods (dunder methods) are special methods surrounded by double underscores that Python calls implicitly. They define how objects behave with operators, built-in functions, and language...

Key Insights

  • Magic methods enable operator overloading and integration with Python’s built-in functions, making custom objects behave like native types
  • __repr__ should return unambiguous developer-facing representations while __str__ provides human-readable output; implementing both prevents confusion in debugging and logging
  • Context managers (__enter__/__exit__), comparison operators, and container protocols transform simple classes into production-ready components with minimal boilerplate

Understanding Magic Methods

Magic methods (dunder methods) are special methods surrounded by double underscores that Python calls implicitly. They define how objects behave with operators, built-in functions, and language constructs. Instead of calling obj.__len__() directly, you write len(obj), and Python handles the magic method invocation.

class DataSet:
    def __init__(self, data):
        self._data = data
    
    def __len__(self):
        return len(self._data)
    
    def __getitem__(self, index):
        return self._data[index]

ds = DataSet([1, 2, 3, 4, 5])
print(len(ds))  # 5
print(ds[2])    # 3

This makes custom objects feel native to Python, improving code readability and reducing the cognitive load for developers using your classes.

String Representation: str vs repr

The distinction between __str__ and __repr__ causes confusion, but the rule is straightforward: __repr__ targets developers, __str__ targets end users.

__repr__ should return a string that, ideally, could recreate the object. It’s what you see in the interactive interpreter and debugger. __str__ provides a readable description for display purposes.

from datetime import datetime

class Task:
    def __init__(self, title, due_date, priority=1):
        self.title = title
        self.due_date = due_date
        self.priority = priority
    
    def __repr__(self):
        return (f"Task(title={self.title!r}, "
                f"due_date={self.due_date!r}, "
                f"priority={self.priority})")
    
    def __str__(self):
        return f"[P{self.priority}] {self.title} (due: {self.due_date.strftime('%Y-%m-%d')})"

task = Task("Deploy to production", datetime(2024, 3, 15), priority=3)
print(repr(task))  # Task(title='Deploy to production', due_date=datetime.datetime(2024, 3, 15, 0, 0), priority=3)
print(str(task))   # [P3] Deploy to production (due: 2024-03-15)
print(task)        # Calls __str__

If you only implement __repr__, Python uses it for both representations. Always implement __repr__ at minimum for better debugging.

Comparison Operators

Implementing comparison magic methods allows objects to be sorted, compared, and used in data structures requiring ordering.

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major, minor, patch):
        self.major = major
        self.minor = minor
        self.patch = patch
    
    def __eq__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor, self.patch) == \
               (other.major, other.minor, other.patch)
    
    def __lt__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor, self.patch) < \
               (other.major, other.minor, other.patch)
    
    def __repr__(self):
        return f"Version({self.major}, {self.minor}, {self.patch})"

versions = [Version(2, 1, 0), Version(1, 9, 5), Version(2, 0, 1)]
sorted_versions = sorted(versions)
print(sorted_versions)  # [Version(1, 9, 5), Version(2, 0, 1), Version(2, 1, 0)]

The @total_ordering decorator generates the remaining comparison methods (__le__, __gt__, __ge__) from __eq__ and __lt__, reducing boilerplate.

Arithmetic and Numeric Operations

Operator overloading makes domain objects expressive. Financial calculations, vector math, and custom numeric types benefit significantly.

class Money:
    def __init__(self, amount, currency="USD"):
        self.amount = amount
        self.currency = currency
    
    def __add__(self, other):
        if isinstance(other, Money):
            if self.currency != other.currency:
                raise ValueError(f"Cannot add {self.currency} and {other.currency}")
            return Money(self.amount + other.amount, self.currency)
        return Money(self.amount + other, self.currency)
    
    def __mul__(self, multiplier):
        return Money(self.amount * multiplier, self.currency)
    
    def __rmul__(self, multiplier):
        # Handles multiplier * money
        return self.__mul__(multiplier)
    
    def __repr__(self):
        return f"Money({self.amount}, {self.currency!r})"
    
    def __str__(self):
        return f"{self.currency} {self.amount:.2f}"

price = Money(19.99)
total = price * 3
print(total)  # USD 59.97

discount = Money(5.00)
final = total + discount
print(final)  # USD 64.97

Implement __radd__, __rmul__, etc., for commutative operations to handle cases where your object is the right operand.

Container Protocol

Implementing container magic methods makes objects iterable, indexable, and compatible with Python’s collection interfaces.

class CircularBuffer:
    def __init__(self, capacity):
        self.capacity = capacity
        self._buffer = []
        self._index = 0
    
    def append(self, item):
        if len(self._buffer) < self.capacity:
            self._buffer.append(item)
        else:
            self._buffer[self._index] = item
        self._index = (self._index + 1) % self.capacity
    
    def __len__(self):
        return len(self._buffer)
    
    def __getitem__(self, index):
        if isinstance(index, slice):
            return [self._buffer[i] for i in range(*index.indices(len(self._buffer)))]
        if index < 0 or index >= len(self._buffer):
            raise IndexError("Index out of range")
        return self._buffer[index]
    
    def __iter__(self):
        return iter(self._buffer)
    
    def __contains__(self, item):
        return item in self._buffer

buffer = CircularBuffer(3)
buffer.append(1)
buffer.append(2)
buffer.append(3)
buffer.append(4)  # Overwrites 1

print(len(buffer))      # 3
print(list(buffer))     # [4, 2, 3]
print(2 in buffer)      # True
print(buffer[0:2])      # [4, 2]

Context Managers

Context managers handle resource setup and teardown. The __enter__ and __exit__ methods enable the with statement.

import time
import logging

class PerformanceTimer:
    def __init__(self, operation_name):
        self.operation_name = operation_name
        self.start_time = None
        
    def __enter__(self):
        self.start_time = time.perf_counter()
        logging.info(f"Starting: {self.operation_name}")
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        elapsed = time.perf_counter() - self.start_time
        if exc_type is not None:
            logging.error(f"Failed: {self.operation_name} after {elapsed:.3f}s")
            return False  # Re-raise exception
        logging.info(f"Completed: {self.operation_name} in {elapsed:.3f}s")
        return True

# Usage
with PerformanceTimer("Database query"):
    time.sleep(0.5)  # Simulate work

The __exit__ method receives exception information. Returning True suppresses the exception, while False (or None) re-raises it.

Callable Objects

Implementing __call__ makes instances callable like functions, useful for stateful operations or configuration.

class RateLimiter:
    def __init__(self, max_calls, period):
        self.max_calls = max_calls
        self.period = period
        self.calls = []
    
    def __call__(self, func):
        def wrapper(*args, **kwargs):
            now = time.time()
            self.calls = [call for call in self.calls if now - call < self.period]
            
            if len(self.calls) >= self.max_calls:
                raise Exception(f"Rate limit exceeded: {self.max_calls} calls per {self.period}s")
            
            self.calls.append(now)
            return func(*args, **kwargs)
        return wrapper

@RateLimiter(max_calls=3, period=60)
def api_call(endpoint):
    return f"Calling {endpoint}"

# First 3 calls succeed, 4th raises exception

Attribute Access Control

Magic methods control attribute access, enabling lazy loading, validation, or proxying.

class ConfigValidator:
    def __init__(self, config_dict):
        self._config = config_dict
        self._required = {'api_key', 'endpoint'}
    
    def __getattr__(self, name):
        # Called when attribute not found normally
        if name in self._config:
            return self._config[name]
        raise AttributeError(f"Configuration '{name}' not found")
    
    def __setattr__(self, name, value):
        if name.startswith('_'):
            # Internal attributes
            super().__setattr__(name, value)
        else:
            # Validate configuration values
            if name in self._required and not value:
                raise ValueError(f"{name} cannot be empty")
            self._config[name] = value
    
    def validate(self):
        missing = self._required - set(self._config.keys())
        if missing:
            raise ValueError(f"Missing required config: {missing}")

config = ConfigValidator({'api_key': 'secret123', 'endpoint': 'https://api.example.com'})
print(config.api_key)  # secret123
config.timeout = 30
config.validate()  # Passes

Use __getattribute__ for intercepting all attribute access (not just missing ones), but be careful to avoid infinite recursion by calling super().__getattribute__().

Practical Patterns

Combine magic methods for production-ready classes:

class CacheEntry:
    def __init__(self, key, value, ttl=3600):
        self.key = key
        self.value = value
        self.created_at = time.time()
        self.ttl = ttl
    
    def __bool__(self):
        # False if expired
        return time.time() - self.created_at < self.ttl
    
    def __repr__(self):
        return f"CacheEntry({self.key!r}, {self.value!r}, ttl={self.ttl})"
    
    def __eq__(self, other):
        if isinstance(other, CacheEntry):
            return self.key == other.key
        return self.key == other
    
    def __hash__(self):
        return hash(self.key)

entry = CacheEntry("user:123", {"name": "Alice"})
if entry:  # Uses __bool__
    print(entry.value)

Magic methods transform classes from simple data containers into first-class Python citizens that integrate seamlessly with the language’s built-in operations and idioms.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.