Python operator overloading magic methods eq lt add total_ordering guide FP2

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.

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.

Similar Posts

2 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *