Skip to main content
Advertisement

Magic Methods (Dunder Methods)

Magic methods (also called dunder methods) are special methods with double underscores (__) on both sides of their name. The Python interpreter calls them automatically when performing specific operations. They allow user-defined classes to behave just like Python's built-in types.


Overview of Magic Methods

# Moments when magic methods are invoked
x = [1, 2, 3]
len(x) # calls x.__len__()
str(x) # calls x.__str__()
x[0] # calls x.__getitem__(0)
x[0] = 10 # calls x.__setitem__(0, 10)
1 in x # calls x.__contains__(1)
x + [4, 5] # calls x.__add__([4, 5])
x == [1, 2, 3] # calls x.__eq__([1, 2, 3])

__str__ vs __repr__

Both methods represent an object as a string, but they serve different purposes.

  • __str__: A human-readable representation (used with print, str())
  • __repr__: An unambiguous representation for developers (repr(), REPL)
from datetime import datetime


class Product:
def __init__(self, name: str, price: float, quantity: int):
self.name = name
self.price = price
self.quantity = quantity
self.created_at = datetime.now()

def __str__(self) -> str:
"""User-friendly representation"""
return f"{self.name} — ${self.price:,.2f} (Stock: {self.quantity})"

def __repr__(self) -> str:
"""Unambiguous developer representation — ideally eval()-reproducible"""
return (f"Product(name={self.name!r}, price={self.price}, "
f"quantity={self.quantity})")


p = Product("Python Book", 35.00, 50)
print(str(p)) # Python Book — $35.00 (Stock: 50)
print(repr(p)) # Product(name='Python Book', price=35.0, quantity=50)
print(p) # Python Book — $35.00 (Stock: 50) (print uses __str__)

# If __str__ is missing, __repr__ is used instead
class OnlyRepr:
def __repr__(self):
return "OnlyRepr()"

o = OnlyRepr()
print(o) # OnlyRepr() (__repr__ used as fallback)
print(repr(o)) # OnlyRepr()

__len__: Supporting len()

class Playlist:
def __init__(self, name: str):
self.name = name
self._songs: list[str] = []

def add(self, song: str) -> None:
self._songs.append(song)

def __len__(self) -> int:
return len(self._songs)

def __bool__(self) -> bool:
"""Used with bool() or if playlist:"""
return len(self._songs) > 0

def __str__(self) -> str:
return f"Playlist({self.name!r}, {len(self)} songs)"


playlist = Playlist("My Playlist")
print(len(playlist)) # 0
print(bool(playlist)) # False

playlist.add("Bohemian Rhapsody")
playlist.add("Hotel California")
print(len(playlist)) # 2
print(bool(playlist)) # True

if playlist:
print(f"{playlist} is ready!")

Overloading Comparison Operators

from functools import total_ordering


@total_ordering # Define __eq__ and one comparison method; the rest are generated automatically
class Version:
"""A class for comparing software versions"""

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

def __eq__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)

def __lt__(self, other: "Version") -> bool:
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)

# @total_ordering auto-generates: __le__, __gt__, __ge__

def __str__(self) -> str:
return f"v{self.major}.{self.minor}.{self.patch}"

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

def __hash__(self) -> int:
"""__hash__ must be defined if __eq__ is defined to use in sets/dicts"""
return hash((self.major, self.minor, self.patch))


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

print(v1 == v4) # True
print(v1 < v2) # True
print(v2 > v1) # True (auto-generated by total_ordering)
print(v3 >= v2) # True (auto-generated by total_ordering)

versions = [v3, v1, v2]
print(sorted(versions)) # [v1.0.0, v1.2.3, v2.0.0]

# Usable in sets (hashable)
version_set = {v1, v2, v4}
print(version_set) # {v1.0.0, v1.2.3} (v1 == v4, so duplicate removed)

Overloading Arithmetic Operators

class Vector2D:
"""A 2D vector class"""

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

def __add__(self, other: "Vector2D") -> "Vector2D":
"""v1 + v2"""
return Vector2D(self.x + other.x, self.y + other.y)

def __sub__(self, other: "Vector2D") -> "Vector2D":
"""v1 - v2"""
return Vector2D(self.x - other.x, self.y - other.y)

def __mul__(self, scalar: float) -> "Vector2D":
"""v * scalar (vector × scalar)"""
return Vector2D(self.x * scalar, self.y * scalar)

def __rmul__(self, scalar: float) -> "Vector2D":
"""scalar * v (scalar × vector — commutative law)"""
return self.__mul__(scalar)

def __truediv__(self, scalar: float) -> "Vector2D":
"""v / scalar"""
if scalar == 0:
raise ZeroDivisionError("Cannot divide by zero.")
return Vector2D(self.x / scalar, self.y / scalar)

def __neg__(self) -> "Vector2D":
"""-v (unary negation)"""
return Vector2D(-self.x, -self.y)

def __abs__(self) -> float:
"""abs(v) — the magnitude of the vector"""
return (self.x ** 2 + self.y ** 2) ** 0.5

def __iadd__(self, other: "Vector2D") -> "Vector2D":
"""v += other (in-place addition)"""
self.x += other.x
self.y += other.y
return self

def dot(self, other: "Vector2D") -> float:
"""Dot product"""
return self.x * other.x + self.y * other.y

def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector2D):
return NotImplemented
return self.x == other.x and self.y == other.y

def __repr__(self) -> str:
return f"Vector2D({self.x}, {self.y})"

def __str__(self) -> str:
return f"({self.x}, {self.y})"


v1 = Vector2D(1, 2)
v2 = Vector2D(3, 4)

print(v1 + v2) # (4, 6)
print(v2 - v1) # (2, 2)
print(v1 * 3) # (3, 6)
print(3 * v1) # (3, 6) — calls __rmul__
print(v2 / 2) # (1.5, 2.0)
print(-v1) # (-1, -2)
print(abs(v2)) # 5.0
print(v1.dot(v2)) # 11

v1 += Vector2D(10, 10)
print(v1) # (11, 12)

__contains__: The in Operator

class IPNetwork:
"""An IP address network range class"""

def __init__(self, start: str, end: str):
self.start = self._ip_to_int(start)
self.end = self._ip_to_int(end)

@staticmethod
def _ip_to_int(ip: str) -> int:
parts = list(map(int, ip.split(".")))
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]

def __contains__(self, ip: str) -> bool:
"""ip in network"""
ip_int = self._ip_to_int(ip)
return self.start <= ip_int <= self.end

def __len__(self) -> int:
return self.end - self.start + 1


local_net = IPNetwork("192.168.1.1", "192.168.1.254")
print("192.168.1.100" in local_net) # True
print("192.168.1.255" in local_net) # False
print("10.0.0.1" in local_net) # False
print(len(local_net)) # 254

__getitem__, __setitem__, __delitem__: Indexing Support

class Matrix:
"""A 2D matrix class"""

def __init__(self, rows: int, cols: int, default: float = 0.0):
self.rows = rows
self.cols = cols
self._data = [[default] * cols for _ in range(rows)]

def __getitem__(self, key: tuple[int, int]) -> float:
"""matrix[row, col]"""
row, col = key
self._check_bounds(row, col)
return self._data[row][col]

def __setitem__(self, key: tuple[int, int], value: float) -> None:
"""matrix[row, col] = value"""
row, col = key
self._check_bounds(row, col)
self._data[row][col] = value

def __delitem__(self, key: tuple[int, int]) -> None:
"""del matrix[row, col] — resets to 0"""
row, col = key
self._check_bounds(row, col)
self._data[row][col] = 0.0

def _check_bounds(self, row: int, col: int) -> None:
if not (0 <= row < self.rows and 0 <= col < self.cols):
raise IndexError(f"Index out of bounds: ({row}, {col})")

def __len__(self) -> int:
return self.rows * self.cols

def __repr__(self) -> str:
rows_str = "\n ".join(str(row) for row in self._data)
return f"Matrix(\n {rows_str}\n)"


m = Matrix(3, 3)
m[0, 0] = 1
m[1, 1] = 5
m[2, 2] = 9

print(m[1, 1]) # 5
print(len(m)) # 9
del m[1, 1]
print(m[1, 1]) # 0.0
print(m)

Practical Example: Card Deck

from __future__ import annotations
import random
from functools import total_ordering


@total_ordering
class Card:
SUITS = ["♠", "♥", "♦", "♣"]
RANKS = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
RANK_VALUES = {r: i for i, r in enumerate(RANKS, 2)}

def __init__(self, rank: str, suit: str):
if rank not in self.RANKS:
raise ValueError(f"Invalid rank: {rank}")
if suit not in self.SUITS:
raise ValueError(f"Invalid suit: {suit}")
self.rank = rank
self.suit = suit

def __str__(self) -> str:
return f"{self.suit}{self.rank}"

def __repr__(self) -> str:
return f"Card({self.rank!r}, {self.suit!r})"

def __eq__(self, other: object) -> bool:
if not isinstance(other, Card):
return NotImplemented
return self.RANK_VALUES[self.rank] == self.RANK_VALUES[other.rank]

def __lt__(self, other: Card) -> bool:
return self.RANK_VALUES[self.rank] < self.RANK_VALUES[other.rank]

def __hash__(self) -> int:
return hash((self.rank, self.suit))


class Deck:
def __init__(self):
self._cards = [Card(r, s) for s in Card.SUITS for r in Card.RANKS]

def __len__(self) -> int:
return len(self._cards)

def __getitem__(self, index: int | slice) -> Card | list[Card]:
return self._cards[index]

def __contains__(self, card: Card) -> bool:
return card in self._cards

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

def __repr__(self) -> str:
return f"Deck({len(self)} cards)"

def shuffle(self) -> None:
random.shuffle(self._cards)

def draw(self) -> Card:
if not self._cards:
raise IndexError("The deck is empty.")
return self._cards.pop()

def deal(self, num_hands: int, cards_per_hand: int) -> list[list[Card]]:
hands = [[] for _ in range(num_hands)]
for _ in range(cards_per_hand):
for hand in hands:
hand.append(self.draw())
return hands


deck = Deck()
print(f"Cards: {len(deck)}")
print(f"First 5: {deck[:5]}")

deck.shuffle()
hands = deck.deal(4, 5)
for i, hand in enumerate(hands, 1):
sorted_hand = sorted(hand)
print(f"Player {i}: {' '.join(str(c) for c in sorted_hand)}")

special_card = Card("A", "♠")
print(f"Is ♠A in the deck? {special_card in deck}")

Context Managers: __enter__ / __exit__

class DatabaseConnection:
"""A DB connection class supporting the with statement"""

def __init__(self, host: str, database: str):
self.host = host
self.database = database
self._connection = None

def __enter__(self) -> "DatabaseConnection":
"""Called when entering the with block"""
print(f"Connecting to {self.host}/{self.database}...")
self._connection = f"conn:{self.host}/{self.database}"
return self

def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
"""Called when exiting the with block (regardless of exceptions)"""
print(f"Closing connection: {self._connection}")
self._connection = None
if exc_type is not None:
print(f"Exception handled: {exc_type.__name__}: {exc_val}")
return False # Returning True suppresses the exception

def query(self, sql: str) -> str:
if not self._connection:
raise RuntimeError("No active connection.")
return f"[{self._connection}] Executing: {sql}"


with DatabaseConnection("localhost", "mydb") as db:
result = db.query("SELECT * FROM users")
print(result)
# __exit__ is automatically called when the block ends → connection closed

Minimizing Comparisons with @functools.total_ordering

from functools import total_ordering


@total_ordering
class Money:
def __init__(self, amount: float, currency: str = "USD"):
self.amount = amount
self.currency = currency

def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError("Cannot compare different currencies")
return self.amount == other.amount

def __lt__(self, other: "Money") -> bool:
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError("Cannot compare different currencies")
return self.amount < other.amount

# total_ordering auto-generates: __le__, __gt__, __ge__

def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)

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


m1 = Money(10.00)
m2 = Money(20.00)
m3 = Money(10.00)

print(m1 == m3) # True
print(m1 < m2) # True
print(m2 > m1) # True (auto-generated)
print(m1 <= m3) # True (auto-generated)
print(m1 + m2) # 30.00 USD

Magic Method Reference Table

MethodCalled WhenExample
__init__Object creationobj = MyClass()
__str__str(), print()str(obj)
__repr__repr(), REPLrepr(obj)
__len__len()len(obj)
__bool__bool(), ifbool(obj)
__eq__==obj == other
__lt__<obj < other
__add__+obj + other
__mul__*obj * scalar
__contains__initem in obj
__getitem__[] readobj[key]
__setitem__[] writeobj[key] = val
__iter__for, iter()for x in obj:
__enter__with entrywith obj as x:
__exit__with exitend of with block
__call__() invocationobj()
__hash__hash(), set, dicthash(obj)

Expert Tips

Use __call__ to Make Instances Behave Like Functions

class Multiplier:
def __init__(self, factor: float):
self.factor = factor

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


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

print(double(5)) # 10.0
print(triple(5)) # 15.0

# Pass an instance where a function is expected
numbers = [1, 2, 3, 4, 5]
print(list(map(double, numbers))) # [2.0, 4.0, 6.0, 8.0, 10.0]
Advertisement