Week 3 Lecture: Magic Methods
Introduction
In Python, magic methods (also known as dunder methods, short for “double underscore”) are special methods whose names are surrounded by double underscores, following the pattern __name__. One such method, __init__, has already been introduced in prior weeks:
class Student:
def __init__(self, name):
self.name = name
The defining characteristic of magic methods is that they are not called directly by the programmer. Instead, Python invokes them automatically in response to specific operations or events. For example, when the expression Student("Alisher") is evaluated, the __init__ method is never explicitly typed by the developer — Python detects that an object is being created and calls the method on the programmer’s behalf.
This principle underlies all magic methods: they are defined within a class, and Python calls them automatically at the appropriate moment. There are dozens of such methods available; this week covers the most essential ones.
__str__
Consider the following class representing a book:
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
When an instance of this class is created and printed, the output may be surprising:
book = Book("Dune", "Frank Herbert")
print(book)
<__main__.Book object at 0x10a0fa560>
Here, __main__ refers to the currently executing script (the default module name), and the hexadecimal value indicates the object’s memory address. This output is rarely useful.
The reason for this behavior is that the print() function must convert the object to a string. It does so by calling the __str__() method on the object. Since no custom __str__ has been defined, Python falls back to a default implementation that simply displays the class name and memory location.
This can be remedied by defining a __str__ method:
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
def __str__(self):
return f"'{self.title}' by {self.author}"
Now, printing the object produces a clean, human-readable result:
book = Book("Dune", "Frank Herbert")
print(book)
The __str__ method is invoked whenever print() is called on the object, or when the object is explicitly converted using str():
str(book)
Important: The __str__ method must return a string — it should not print one. Python handles the printing step separately.
__repr__
While __str__ provides a user-friendly string representation, Python also supports a developer-oriented counterpart: __repr__, short for representation.
The distinction between the two is as follows:
__str__is intended for end users — those who interact with the program’s output.__repr__is intended for developers — those who debug or inspect the program.
As a best practice, the output of __repr__ should contain enough information to recreate the object in Python code.
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
def __str__(self):
return f"'{self.title}' by {self.author}"
def __repr__(self):
return f"Book('{self.title}', '{self.author}')"
The two methods can be compared directly:
book = Book("Dune", "Frank Herbert")
print(str(book)) # calls __str__
print(repr(book)) # calls __repr__
# 'Dune' by Frank Herbert
# Book('Dune', 'Frank Herbert')
The __str__ output is clean and readable, while the __repr__ output resembles the constructor call needed to recreate the object — immediately informing a developer of the object’s type and state.
Which Method Is Used Where?
When an object appears inside an f-string, Python calls __str__:
f"My book: {book}"
However, when an object is displayed as an element inside a container — such as a list or dictionary — Python calls __repr__ for the inner objects:
print([book])
print({
'book': book
})
Fallback Behavior
If only __repr__ is defined (and __str__ is not), Python will use __repr__ for all string conversions, including calls to print() and str().
The reverse is not true: if only __str__ is defined, Python will not use it as a fallback for repr(). A user-friendly representation is not considered safe or appropriate for developer-oriented contexts.
__add__
When the expression 3 + 5 is evaluated, Python internally translates it into a method call:
(3).__add__(5)
Since everything in Python is an object, the integer 3 possesses a method called __add__, which Python invokes with 5 as the argument. The same mechanism applies to strings:
"hello" + " world"
# is equivalent to
"hello".__add__(" world")
This mechanism can be extended to custom classes. Consider the following Wallet class:
class Wallet:
def __init__(self, amount):
self.amount = amount
def __str__(self):
return f"Wallet(${self.amount})"
Attempting to add two Wallet instances without a defined __add__ method results in an error:
w1 = Wallet(50)
w2 = Wallet(30)
w3 = w1 + w2 # TypeError!
TypeError: unsupported operand type(s) for +: 'Wallet' and 'Wallet'
Python does not know how to add two Wallet objects together unless explicitly instructed. The __add__ method can be defined to resolve this. The method accepts another object (conventionally named other), adds the amounts from both wallets, creates a new Wallet with the combined total, and returns it:
class Wallet:
def __init__(self, amount):
self.amount = amount
def __str__(self):
return f"Wallet(${self.amount})"
def __add__(self, other):
new_amount = self.amount + other.amount
return Wallet(new_amount)
Notably, the original wallets remain unmodified — a new Wallet is returned:
w1 = Wallet(50)
w2 = Wallet(30)
w3 = w1 + w2
print(w3) # Wallet($80)
print(w1) # Wallet($50) — unchanged!
This is an important design pattern: arithmetic operators should return a new object rather than modifying existing ones. This mirrors the behavior of Python’s built-in types — adding 3 + 5 produces a new integer 8 without altering 3 or 5 — and users of custom classes will expect the same semantics.
Handling Multiple Operand Types with isinstance()
A natural extension is to support adding a plain number to a Wallet, such as w1 + 20. The current implementation would fail because it attempts to access other.amount, and integers do not have an amount attribute.
To handle this, the built-in function isinstance() can be used. The expression isinstance(obj, SomeClass) returns True if obj is an instance of SomeClass:
print(isinstance(42, int)) # True — 42 is an int
print(isinstance("hello", str)) # True — "hello" is a str
print(isinstance(42, str)) # False — 42 is not a str
Multiple types can be checked simultaneously by passing a tuple of classes:
print(isinstance(42, (int, float))) # True — 42 is int or float
print(isinstance(3.14, (int, float))) # True — 3.14 is int or float
While type() can also be used for type checking, isinstance() is generally preferred. The advantages of isinstance() will become apparent in the week on inheritance.
Using isinstance(), the __add__ method can be extended to handle both Wallet and numeric operands:
def __add__(self, other):
if isinstance(other, Wallet):
return Wallet(self.amount + other.amount)
elif isinstance(other, (int, float)):
return Wallet(self.amount + other)
else:
return NotImplemented
When neither case applies, the method returns NotImplemented. This is a special built-in value that signals to Python: “this operation is not supported for the given operand type.” It is important to note that NotImplemented is not an exception — it is not raised, but returned. Upon receiving this value, Python will attempt alternative resolution strategies before ultimately raising a TypeError.
__eq__
By default, the == operator compares object identity rather than value — that is, it checks whether two variables reference the same object in memory:
w1 = Wallet(50)
w2 = Wallet(50)
print(w1 == w2) # False
Although w1 and w2 contain the same amount, they are two distinct objects residing at different memory locations, and the comparison returns False.
To compare wallets by their amount attribute instead, the __eq__ method must be defined:
class Wallet:
def __init__(self, amount):
self.amount = amount
def __str__(self):
return f"Wallet(${self.amount})"
def __repr__(self):
return f"Wallet({self.amount})"
def __add__(self, other):
if isinstance(other, Wallet):
return Wallet(self.amount + other.amount)
elif isinstance(other, (int, float)):
return Wallet(self.amount + other)
return NotImplemented
def __eq__(self, other):
if isinstance(other, Wallet):
return self.amount == other.amount
return NotImplemented
w1 = Wallet(50)
w2 = Wallet(50)
w3 = Wallet(100)
print(w1 == w2) # True
print(w1 == w3) # False
A Note on the Breadth of Magic Methods
There are many more magic methods beyond those presented here. It is not necessary to memorize all of them. The key principle is that nearly every operator (+, -, ==, <, etc.) and built-in function (len, str, repr, etc.) has a corresponding magic method. To enable a custom object to support a particular operator or function, the appropriate dunder method must be defined within its class.
__bool__
Python’s built-in types support truthiness testing. For example, an empty list evaluates to False in a boolean context, while a non-empty list evaluates to True:
my_list = []
if not my_list:
print(empty)
bool(my_list)
This behavior is governed by the __bool__ magic method. Custom classes can implement the same behavior. For the Wallet class, a natural definition would treat a wallet as truthy if its amount is greater than zero:
def __bool__(self):
return self.amount > 0
This enables intuitive boolean checks:
w1 = Wallet(50)
w2 = Wallet(0)
if w1:
print("w1 has money") # this prints
if not w2:
print("w2 is empty") # this prints too
__len__
Consider a Playlist class that stores a name and a list of songs:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
After creating a playlist and adding songs, one might naturally attempt to determine its length:
p = Playlist("My Favorites")
p.add_song("Blue Monday")
p.add_song("Bohemian Rhapsody")
len(p)
# TypeError: object of type 'Playlist' has no len()
The TypeError occurs because no __len__ method has been defined. Once implemented, the len() function can be used on Playlist instances. In this case, the logical length of a playlist is the number of songs it contains:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __len__(self):
return len(self.songs)
The __len__ method can also be used internally by other methods within the same class. For example, a __str__ method can reference len(self) to include the song count in its output:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __len__(self):
return len(self.songs)
def __str__(self):
return f"Playlist '{self.name}' ({len(self)} songs)"
print(p) # Playlist 'My Favorites' (2 songs)
This demonstrates that an object can invoke its own magic methods, creating a cohesive and self-consistent interface.
Comprehensive Example: The Vector Class
To illustrate how multiple magic methods work together in a single class, consider a two-dimensional Vector class — a construct frequently used in game development and computational geometry.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"({self.x}, {self.y})"
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other):
if isinstance(other, Vector):
return Vector(self.x + other.x, self.y + other.y)
return NotImplemented
def __sub__(self, other):
if isinstance(other, Vector):
return Vector(self.x - other.x, self.y - other.y)
return NotImplemented
def __eq__(self, other):
if isinstance(other, Vector):
return self.x == other.x and self.y == other.y
return NotImplemented
def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Vector(self.x * scalar, self.y * scalar)
return NotImplemented
def __bool__(self):
return self.x != 0 or self.y != 0
This class supports addition, subtraction, scalar multiplication, equality comparison, and boolean evaluation:
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2) # (4, 6)
print(v1 - v2) # (2, 2)
print(v1 * 3) # (9, 12)
print(v1 == v2) # False
print(v1 == Vector(3, 4)) # True
zero = Vector(0, 0)
if not zero:
print("This is a zero vector") # prints
Each operator in this example maps directly to its corresponding magic method, enabling Vector objects to participate in arithmetic expressions with the same natural syntax as Python’s built-in numeric types.
Appendix: Common Magic Methods
Object lifecycle
__init____del__
String representation
__str____repr__
Comparison operators
__eq____ne____lt____le____gt____ge__
Arithmetic operators
__add____sub____mul____truediv____floordiv____mod____pow__
In-place (augmented) arithmetic operators
__iadd____isub____imul____itruediv____ifloordiv____imod____ipow____imatmul__
Unary operators
__neg____pos____abs__
Container / collection methods
__len____getitem____setitem____delitem____contains__
Callable
__call__
This content will be available starting February 23, 2026.