Magic Methods (Dunder Methods)

Difficulty: Intermediate

Magic methods (also called dunder methods, short for double underscore) are special methods with names surrounded by double underscores. They let you hook into Python's built-in behavior and make your custom objects work with operators, built-in functions, and language constructs like for-loops and with-statements. They are never called directly by name; instead, Python calls them automatically when you use the corresponding syntax.

__str__ and __repr__ control how your objects are converted to strings. __str__ is called by print() and str() and should return a human-friendly description. __repr__ is called by the interactive interpreter and repr(), and should ideally return a string that could recreate the object. If only one is defined, Python falls back from __str__ to __repr__ (but not vice versa), so if you define only one, make it __repr__.

__eq__ and __lt__ are comparison methods. __eq__ defines behavior for == and should return True or False. Without __eq__, Python defaults to identity comparison (same as 'is'). __lt__ defines behavior for < and, when combined with the functools.total_ordering decorator, can auto-generate <=, >, and >= as well. These methods are crucial for sorting and using your objects in sets or as dictionary keys.

__len__ and __getitem__ make your object behave like a collection. __len__ is called by the len() function. __getitem__ is called when you use square bracket notation obj[key]. Implementing __getitem__ also makes your object iterable (you can use it in a for-loop) because Python will call __getitem__ with increasing integer indices until an IndexError is raised. For more control over iteration, implement __iter__ and __next__ instead.

__add__, __sub__, __mul__, and other arithmetic dunder methods define how operators work with your objects. __add__ handles the + operator, __sub__ handles -, and so on. There are also reflected versions like __radd__ that are called when your object appears on the right side of the operator and the left object does not know how to handle the operation. These methods should return new objects rather than modifying the originals, consistent with how built-in types behave.

Code examples

__str__ vs __repr__

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    def __str__(self):
        return f'{self.title} by {self.author}'

    def __repr__(self):
        return f"Book('{self.title}', '{self.author}', {self.pages})"

b = Book('1984', 'Orwell', 328)
print(str(b))
print(repr(b))
print(b)  # print calls __str__

__str__ returns a readable description for end users. __repr__ returns a detailed string that could recreate the object. print() calls __str__ by default.

__eq__ and __lt__ for comparison and sorting

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

    def __eq__(self, other):
        return self.grade == other.grade

    def __lt__(self, other):
        return self.grade < other.grade

    def __repr__(self):
        return f'{self.name}:{self.grade}'

students = [Student('Alice', 88), Student('Bob', 95), Student('Charlie', 72)]
students.sort()
print(students)
print(Student('X', 88) == Student('Y', 88))
print(Student('X', 70) < Student('Y', 90))

sort() uses __lt__ to order the students by grade. __eq__ compares grades regardless of name, so two students with the same grade are considered equal. __repr__ controls how they appear in the list printout.

__len__ and __getitem__ for collection behavior

class Deck:
    def __init__(self):
        suits = ['Hearts', 'Diamonds', 'Clubs', 'Spades']
        ranks = ['A', '2', '3']
        self.cards = [f'{r} of {s}' for s in suits for r in ranks]

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

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

deck = Deck()
print(len(deck))
print(deck[0])
print(deck[-1])
print(deck[2:5])

__len__ lets len() work on the deck. __getitem__ supports integer indexing, negative indexing, and slicing because Python's slice objects are passed directly to __getitem__ and the internal list handles them.

__add__ and __mul__ for arithmetic

class Matrix:
    def __init__(self, rows):
        self.rows = rows

    def __add__(self, other):
        result = []
        for r1, r2 in zip(self.rows, other.rows):
            result.append([a + b for a, b in zip(r1, r2)])
        return Matrix(result)

    def __mul__(self, scalar):
        result = [[val * scalar for val in row] for row in self.rows]
        return Matrix(result)

    def __repr__(self):
        return '\n'.join(str(row) for row in self.rows)

m1 = Matrix([[1, 2], [3, 4]])
m2 = Matrix([[5, 6], [7, 8]])
print(m1 + m2)
print('---')
print(m1 * 3)

__add__ performs element-wise addition of two matrices. __mul__ multiplies every element by a scalar. Both return new Matrix objects. __repr__ formats the matrix row by row.

Key points

Concepts covered

__str__, __repr__, __len__, __eq__, __lt__, __add__, __getitem__, dunder methods