Operator overloading in Python — magicmethods for your own classes
Operator overloading in Python is what lets your custom classes behave like built-in types. When you write 3 + 5 Python calls int.__add__(3, 5). When you write 'hello' + ' world' Python calls str.__add__('hello', ' world'). Operator overloading lets you define what +, -, <, ==, len() and many other operations mean for your own classes — so that vector1 + vector2, account1 > account2 and if my_stack: all work naturally.
Table of Contents
Why operator overloading matters
Without it, every class needs awkward method calls:
# Without overloading — verbose result = vector1.add(vector2) if vector1.less_than(vector2): if not my_stack.is_empty(): total = fraction1.add(fraction2) # With overloading — natural result = vector1 + vector2 if vector1 < vector2: if my_stack: total = fraction1 + fraction2
The second version reads like mathematics — and Python lets you write it exactly like that.
The magic methods (dunder methods)
Every operator in Python corresponds to a magic method — a method with double underscores before and after the name. Python calls them automatically when the corresponding operator is used:
a + b → a.__add__(b) a - b → a.__sub__(b) a * b → a.__mul__(b) a / b → a.__truediv__(b) a // b → a.__floordiv__(b) a % b → a.__mod__(b) a ** b → a.__pow__(b) a == b → a.__eq__(b) a != b → a.__ne__(b) a < b → a.__lt__(b) a <= b → a.__le__(b) a > b → a.__gt__(b) a >= b → a.__ge__(b) len(a) → a.__len__() bool(a) → a.__bool__() str(a) → a.__str__() repr(a) → a.__repr__() abs(a) → a.__abs__() -a → a.__neg__() +a → a.__pos__()
Comparison operators — eq and lt
These two are the most important and the ones you’ll implement most often in FP2:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@property
def fahrenheit(self):
return round(self._celsius * 9/5 + 32, 2)
def __eq__(self, other):
if isinstance(other, Temperature):
return self._celsius == other._celsius
if isinstance(other, (int, float)): # compare with a number
return self._celsius == other
return NotImplemented # don't know how to compare
def __lt__(self, other):
if isinstance(other, Temperature):
return self._celsius < other._celsius
if isinstance(other, (int, float)):
return self._celsius < other
return NotImplemented
def __str__(self):
return f'{self._celsius}°C'
def __repr__(self):
return f'Temperature({self._celsius})'
t1 = Temperature(100) t2 = Temperature(0) t3 = Temperature(100) print(t1 == t3) # → True print(t1 == t2) # → False print(t1 > t2) # → True (Python derives > from < automatically with __lt__) print(t2 < t1) # → True temps = [Temperature(37), Temperature(0), Temperature(100), Temperature(20)] print(sorted(temps)) # → [0°C, 20°C, 37°C, 100°C] print(min(temps)) # → 0°C print(max(temps)) # → 100°C
Important: returning NotImplemented (not the same as return None or raise NotImplementedError) tells Python “I don’t know how to handle this comparison — try the other object’s method instead”. This is the correct way to handle comparisons between incompatible types.
@total_ordering — derive all comparison operators from two
If you define __eq__ and __lt__, Python can derive __le__, __gt__ and __ge__ automatically using the @total_ordering decorator:
from functools import total_ordering
@total_ordering
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
def __eq__(self, other):
if isinstance(other, Temperature):
return self._celsius == other._celsius
return NotImplemented
def __lt__(self, other):
if isinstance(other, Temperature):
return self._celsius < other._celsius
return NotImplemented
def __str__(self):
return f'{self._celsius}°C'
t1 = Temperature(50) t2 = Temperature(30) # All of these work now — derived automatically print(t1 > t2) # → True (derived from < and ==) print(t1 >= t2) # → True (derived from < and ==) print(t1 <= t2) # → False (derived from <) print(t1 != t2) # → True (derived from ==)
Arithmetic operators — add, sub, mul
class Vector2D:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
if isinstance(other, Vector2D):
return Vector2D(self.x + other.x, self.y + other.y)
return NotImplemented
def __sub__(self, other):
if isinstance(other, Vector2D):
return Vector2D(self.x - other.x, self.y - other.y)
return NotImplemented
def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Vector2D(self.x * scalar, self.y * scalar)
return NotImplemented
def __truediv__(self, scalar):
if isinstance(scalar, (int, float)):
if scalar == 0:
raise ZeroDivisionError('Cannot divide vector by zero')
return Vector2D(self.x / scalar, self.y / scalar)
return NotImplemented
def __neg__(self):
return Vector2D(-self.x, -self.y)
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def __eq__(self, other):
return isinstance(other, Vector2D) and self.x == other.x and self.y == other.y
def __str__(self):
return f'({self.x}, {self.y})'
def __repr__(self):
return f'Vector2D({self.x}, {self.y})'
v1 = Vector2D(3, 4) v2 = Vector2D(1, 2) print(v1 + v2) # → (4, 6) print(v1 - v2) # → (2, 2) print(v1 * 3) # → (9, 12) print(v1 / 2) # → (1.5, 2.0) print(-v1) # → (-3, -4) print(abs(v1)) # → 5.0 (magnitude: √(3²+4²) = 5)
Reflected operators — when the left operand doesn’t know what to do
What happens when you write 3 * v1 instead of v1 * 3? Python first tries int.__mul__(3, v1) — which fails because int doesn’t know how to multiply by a Vector2D. Then Python tries the reflected version: Vector2D.__rmul__(v1, 3):
class Vector2D:
# ... previous methods ...
def __rmul__(self, scalar):
"""Called when scalar * vector — delegates to __mul__."""
return self.__mul__(scalar)
v = Vector2D(2, 3) print(v * 4) # → (8, 12) — calls v.__mul__(4) print(4 * v) # → (8, 12) — calls v.__rmul__(4) because int doesn't know
The reflected versions follow the pattern: __radd__, __rsub__, __rmul__, __rtruediv__, etc.
In-place operators — iadd, isub
In-place operators (+=, -=, *=) have their own magic methods. If you don’t define them, Python falls back to the regular arithmetic method plus assignment — but defining them explicitly lets you modify the object in place:
class Vector2D:
# ... previous methods ...
def __iadd__(self, other):
"""v1 += v2 — modifies v1 in place."""
if isinstance(other, Vector2D):
self.x += other.x
self.y += other.y
return self # must return self for in-place
return NotImplemented
def __imul__(self, scalar):
"""v *= 3 — modifies v in place."""
if isinstance(scalar, (int, float)):
self.x *= scalar
self.y *= scalar
return self
return NotImplemented
v = Vector2D(1, 2) v += Vector2D(3, 4) # calls __iadd__ — modifies v in place print(v) # → (4, 6) v *= 2 # calls __imul__ print(v) # → (8, 12)
Container operators — len, getitem, contains
These make your class behave like a Python container:
class NumberSet:
def __init__(self, *numbers):
self._numbers = list(numbers)
def add(self, number):
if number not in self._numbers:
self._numbers.append(number)
def __len__(self):
return len(self._numbers)
def __getitem__(self, index):
return self._numbers[index]
def __contains__(self, item):
return item in self._numbers
def __iter__(self):
return iter(self._numbers)
def __bool__(self):
return len(self._numbers) > 0
def __str__(self):
return f'NumberSet{tuple(self._numbers)}'
def __add__(self, other):
"""Union of two sets."""
if isinstance(other, NumberSet):
result = NumberSet(*self._numbers)
for n in other:
result.add(n)
return result
return NotImplemented
s1 = NumberSet(1, 2, 3, 4, 5)
s2 = NumberSet(4, 5, 6, 7)
print(len(s1)) # → 5 (calls __len__)
print(s1[2]) # → 3 (calls __getitem__)
print(3 in s1) # → True (calls __contains__)
print(9 in s1) # → False
for n in s1: # calls __iter__
print(n, end=' ')
# → 1 2 3 4 5
if s1: # calls __bool__
print('Set is not empty')
union = s1 + s2 # calls __add__
print(union) # → NumberSet(1, 2, 3, 4, 5, 6, 7)
A complete practical example — Fraction class
The classic example that uses almost every arithmetic operator:
from math import gcd
from functools import total_ordering
@total_ordering
class Fraction:
def __init__(self, numerator, denominator):
if denominator == 0:
raise ZeroDivisionError('Denominator cannot be zero')
# Normalise sign — keep negative in numerator
if denominator < 0:
numerator = -numerator
denominator = -denominator
# Simplify using GCD
common = gcd(abs(numerator), denominator)
self._num = numerator // common
self._den = denominator // common
@property
def numerator(self):
return self._num
@property
def denominator(self):
return self._den
def __add__(self, other):
if isinstance(other, Fraction):
return Fraction(
self._num * other._den + other._num * self._den,
self._den * other._den
)
if isinstance(other, int):
return Fraction(self._num + other * self._den, self._den)
return NotImplemented
def __radd__(self, other):
return self.__add__(other)
def __sub__(self, other):
if isinstance(other, Fraction):
return Fraction(
self._num * other._den - other._num * self._den,
self._den * other._den
)
if isinstance(other, int):
return Fraction(self._num - other * self._den, self._den)
return NotImplemented
def __mul__(self, other):
if isinstance(other, Fraction):
return Fraction(self._num * other._num, self._den * other._den)
if isinstance(other, int):
return Fraction(self._num * other, self._den)
return NotImplemented
def __rmul__(self, other):
return self.__mul__(other)
def __truediv__(self, other):
if isinstance(other, Fraction):
return Fraction(self._num * other._den, self._den * other._num)
if isinstance(other, int):
return Fraction(self._num, self._den * other)
return NotImplemented
def __neg__(self):
return Fraction(-self._num, self._den)
def __abs__(self):
return Fraction(abs(self._num), self._den)
def __eq__(self, other):
if isinstance(other, Fraction):
return self._num == other._num and self._den == other._den
if isinstance(other, int):
return self._den == 1 and self._num == other
return NotImplemented
def __lt__(self, other):
if isinstance(other, Fraction):
return self._num * other._den < other._num * self._den
if isinstance(other, int):
return self._num < other * self._den
return NotImplemented
def __float__(self):
return self._num / self._den
def __int__(self):
return self._num // self._den
def __str__(self):
if self._den == 1:
return str(self._num)
return f'{self._num}/{self._den}'
def __repr__(self):
return f'Fraction({self._num}, {self._den})'
a = Fraction(1, 2) # 1/2 b = Fraction(1, 3) # 1/3 c = Fraction(2, 4) # 1/2 after simplification print(a + b) # → 5/6 (1/2 + 1/3 = 3/6 + 2/6 = 5/6) print(a - b) # → 1/6 print(a * b) # → 1/6 print(a / b) # → 3/2 print(-a) # → -1/2 print(abs(Fraction(-3, 4))) # → 3/4 print(a == c) # → True (both simplify to 1/2) print(a < b) # → False (1/2 > 1/3) print(float(a)) # → 0.5 # These work because of __radd__ and __rmul__ print(1 + a) # → 3/2 print(2 * a) # → 1 # Sorting works because of __lt__ fractions = [Fraction(3, 4), Fraction(1, 2), Fraction(1, 3)] print(sorted(fractions)) # → [1/3, 1/2, 3/4]
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
return self.__mul__(scalar)
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __str__(self):
return f'({self.x}, {self.y})'
v1 = Vector(1, 2)
v2 = Vector(3, 4)
v3 = v1 + v2 # calls __add__
print(v3)
v4 = v1 * 3 # calls __mul__
print(v4)
v5 = 3 * v1 # calls __rmul__ (int.__mul__ fails first)
print(v5)
print(v4 == v5) # calls __eq__
Step through and observe three moments. When v1 + v2 is called, Python calls v1.__add__(v2) which creates a new Vector object — the originals v1 and v2 are unchanged. When v1 * 3 is called, Python calls v1.__mul__(3) — same direction. When 3 * v1 is called, Python first tries int.__mul__(3, v1) — int doesn’t know how to handle a Vector, so it returns NotImplemented. Python then tries v1.__rmul__(3) — which works. The reflected method is the fallback for when the left operand can’t handle the operation.
Quick summary
# ============================================
# OPERATOR OVERLOADING CHEAT SHEET
# Sergio Learns · sergiolearns.com
# ============================================
# COMPARISON
def __eq__(self, other): ... # ==
def __lt__(self, other): ... #
# with @total_ordering, define __eq__ + __lt__
# and get >, >=, <=, != automatically
from functools import total_ordering
@total_ordering
class MyClass:
def __eq__(self, other): ...
def __lt__(self, other): ...
# >, >=, <=, != derived automatically
# ARITHMETIC
def __add__(self, other): ... # a + b
def __sub__(self, other): ... # a - b
def __mul__(self, other): ... # a * b
def __truediv__(self, other): ... # a / b
def __floordiv__(self, other): ... # a // b
def __mod__(self, other): ... # a % b
def __pow__(self, other): ... # a ** b
def __neg__(self): ... # -a
def __pos__(self): ... # +a
def __abs__(self): ... # abs(a)
# REFLECTED (right operand fallback)
def __radd__(self, other): ... # other + self
def __rmul__(self, other): ... # other * self
# (etc. for all arithmetic ops)
# IN-PLACE
def __iadd__(self, other): ...; return self # a += b
def __imul__(self, other): ...; return self # a *= b
# must return self for in-place operators
# CONTAINER
def __len__(self): ... # len(obj)
def __bool__(self): ... # bool(obj), if obj:
def __getitem__(self, i): ... # obj[i]
def __setitem__(self, i, v): ... # obj[i] = v
def __contains__(self, x): ... # x in obj
def __iter__(self): ... # for x in obj:
# RETURN NotImplemented (not None, not raise)
# when you can't handle the type
def __add__(self, other):
if isinstance(other, MyClass):
return MyClass(self.val + other.val)
return NotImplemented # signals Python to try reflected method
# CONVERSION
def __int__(self): ... # int(obj)
def __float__(self): ... # float(obj)
def __str__(self): ... # str(obj), print(obj)
def __repr__(self): ... # repr(obj), shell display
# KEY RULES
# 1. Always check isinstance(other, ExpectedType)
# 2. Return NotImplemented for unknown types — not raise
# 3. In-place operators must return self
# 4. Reflected methods needed when left operand is a built-in type
# 5. @total_ordering saves implementing all 6 comparisons manually
# 6. __eq__ should return bool, __add__ should return new object
In the next article we practice operator overloading with three complete programs — a matrix class, a money class and a polynomial.

2 Comments