Magic Methods (Dunder Methods)

0/5 in this phase0/54 across the roadmap

📖 Concept

Magic methods (also called dunder methods for "double underscore") are special methods that Python calls implicitly to perform operations. They let you customize how objects behave with operators, built-in functions, and language constructs.

Categories of magic methods:

Category Methods Triggered By
Construction __new__, __init__, __del__ Object creation/destruction
String __str__, __repr__, __format__ str(), repr(), f"{}"
Comparison __eq__, __lt__, __le__, etc. ==, <, <=
Arithmetic __add__, __sub__, __mul__, etc. +, -, *
Container __len__, __getitem__, __contains__ len(), [], in
Callable __call__ obj()
Context __enter__, __exit__ with statement
Attribute __getattr__, __setattr__ . access
Hashing __hash__ hash(), dict keys

Essential rules:

  • __str__ is for end-users (readable), __repr__ is for developers (unambiguous). If only one is defined, define __repr__.
  • If you define __eq__, you should usually define __hash__ too (or set it to None to make the object unhashable).
  • Arithmetic methods have "reflected" versions (__radd__, __rsub__) for when the left operand doesn't support the operation.
  • Use @functools.total_ordering to auto-generate comparison methods from just __eq__ and __lt__.

💻 Code Example

codeTap to expand ⛶
1# ============================================================
2# String representation: __str__ and __repr__
3# ============================================================
4class Vector:
5 def __init__(self, x, y):
6 self.x = x
7 self.y = y
8
9 def __repr__(self):
10 """Unambiguous representation (for developers)."""
11 return f"Vector({self.x}, {self.y})"
12
13 def __str__(self):
14 """Readable representation (for users)."""
15 return f"({self.x}, {self.y})"
16
17 def __format__(self, spec):
18 """Custom formatting: f'{v:.2f}'"""
19 if spec:
20 return f"({self.x:{spec}}, {self.y:{spec}})"
21 return str(self)
22
23v = Vector(3.14159, 2.71828)
24print(repr(v)) # Vector(3.14159, 2.71828)
25print(str(v)) # (3.14159, 2.71828)
26print(f"{v:.2f}") # (3.14, 2.72)
27
28# ============================================================
29# Arithmetic operators
30# ============================================================
31class Vector:
32 def __init__(self, x, y):
33 self.x = x
34 self.y = y
35
36 def __add__(self, other):
37 if isinstance(other, Vector):
38 return Vector(self.x + other.x, self.y + other.y)
39 return NotImplemented
40
41 def __sub__(self, other):
42 if isinstance(other, Vector):
43 return Vector(self.x - other.x, self.y - other.y)
44 return NotImplemented
45
46 def __mul__(self, scalar):
47 if isinstance(scalar, (int, float)):
48 return Vector(self.x * scalar, self.y * scalar)
49 return NotImplemented
50
51 def __rmul__(self, scalar):
52 """Reflected multiply: allows 3 * vector."""
53 return self.__mul__(scalar)
54
55 def __abs__(self):
56 """Magnitude: abs(vector)."""
57 return (self.x ** 2 + self.y ** 2) ** 0.5
58
59 def __neg__(self):
60 return Vector(-self.x, -self.y)
61
62 def __repr__(self):
63 return f"Vector({self.x}, {self.y})"
64
65v1 = Vector(1, 2)
66v2 = Vector(3, 4)
67
68print(v1 + v2) # Vector(4, 6)
69print(v1 - v2) # Vector(-2, -2)
70print(v1 * 3) # Vector(3, 6)
71print(3 * v1) # Vector(3, 6) — works because of __rmul__
72print(abs(v2)) # 5.0
73print(-v1) # Vector(-1, -2)
74
75# ============================================================
76# Comparison operators with total_ordering
77# ============================================================
78from functools import total_ordering
79
80@total_ordering
81class Temperature:
82 def __init__(self, celsius):
83 self.celsius = celsius
84
85 def __eq__(self, other):
86 if not isinstance(other, Temperature):
87 return NotImplemented
88 return self.celsius == other.celsius
89
90 def __lt__(self, other):
91 if not isinstance(other, Temperature):
92 return NotImplemented
93 return self.celsius < other.celsius
94
95 def __hash__(self):
96 return hash(self.celsius)
97
98 def __repr__(self):
99 return f"Temperature({self.celsius}°C)"
100
101t1 = Temperature(20)
102t2 = Temperature(30)
103t3 = Temperature(20)
104
105print(t1 < t2) # True
106print(t1 >= t3) # True (auto-generated by total_ordering)
107print(t1 == t3) # True
108print(sorted([t2, t1, Temperature(25)])) # [20°C, 25°C, 30°C]
109
110# Can use as dict key because __hash__ is defined
111temps = {t1: "comfortable", t2: "hot"}
112
113# ============================================================
114# Container protocol: __len__, __getitem__, __contains__
115# ============================================================
116class Deck:
117 """A deck of playing cards."""
118 suits = ["Hearts", "Diamonds", "Clubs", "Spades"]
119 ranks = ["2", "3", "4", "5", "6", "7", "8", "9", "10",
120 "Jack", "Queen", "King", "Ace"]
121
122 def __init__(self):
123 self.cards = [
124 f"{rank} of {suit}"
125 for suit in self.suits
126 for rank in self.ranks
127 ]
128
129 def __len__(self):
130 return len(self.cards)
131
132 def __getitem__(self, index):
133 return self.cards[index]
134
135 def __contains__(self, card):
136 return card in self.cards
137
138 def __iter__(self):
139 return iter(self.cards)
140
141deck = Deck()
142print(len(deck)) # 52
143print(deck[0]) # "2 of Hearts"
144print(deck[-1]) # "Ace of Spades"
145print(deck[10:13]) # Slicing works!
146print("Ace of Spades" in deck) # True
147
148# Iteration works because of __iter__
149for card in deck:
150 if "Ace" in card:
151 print(card)
152
153# ============================================================
154# __call__ — making objects callable
155# ============================================================
156class Validator:
157 def __init__(self, min_val, max_val):
158 self.min_val = min_val
159 self.max_val = max_val
160
161 def __call__(self, value):
162 """Makes the object callable like a function."""
163 if not self.min_val <= value <= self.max_val:
164 raise ValueError(
165 f"{value} not in range [{self.min_val}, {self.max_val}]"
166 )
167 return True
168
169# Use like a function
170validate_age = Validator(0, 150)
171validate_score = Validator(0, 100)
172
173print(validate_age(25)) # True
174print(validate_score(85)) # True
175# validate_age(200) # ValueError
176
177# Callable check
178print(callable(validate_age)) # True

🏋️ Practice Exercise

Exercises:

  1. Create a Money class with currency and amount. Implement +, -, *, comparison operators, and __format__ for currency display. Raise errors for different currencies.

  2. Build a Matrix class that supports +, * (matrix multiplication), len(), [row][col] indexing, and __repr__.

  3. Implement a Range-like class with __len__, __getitem__, __contains__, and __iter__. Support slicing via __getitem__.

  4. Create a Throttle class using __call__ that limits how many times a function can be called per second.

  5. Implement __enter__ and __exit__ to create a Timer context manager: with Timer() as t: ... prints elapsed time.

  6. Build a class that supports attribute access via dot notation AND dictionary syntax by implementing __getattr__ and __getitem__.

⚠️ Common Mistakes

  • Only implementing __str__ without __repr__. __repr__ is used in more contexts (debugger, containers, fallback for str()). Always implement __repr__; optionally add __str__.

  • Returning something other than NotImplemented when an operation isn't supported. Return NotImplemented (not raise NotImplementedError) to let Python try the reflected method on the other operand.

  • Defining __eq__ without __hash__. In Python 3, if you define __eq__, __hash__ is set to None (making instances unhashable). Define __hash__ if objects need to be dict keys or set members.

  • Using magic methods directly: obj.__len__() instead of len(obj). The built-in functions have additional checks and optimizations. Always use the built-in functions.

  • Implementing too many magic methods. Only implement those that make sense for your class. A User object probably shouldn't support + or * operators.

💼 Interview Questions

🎤 Mock Interview

Practice a live interview for Magic Methods (Dunder Methods)