I've been diving deep into Python operators lately. Not just using them, but understanding how they work under the hood. It turns out there are two related but distinct concepts: the operator module (a stdlib treasure), and custom operators (dunder methods you implement yourself). Let's explore both.
The operator Module: Lambda's Elegant Replacement
The operator module gives you function versions of Python's operators. Why would you want that? Because sorted(items, key=itemgetter('name')) is cleaner than sorted(items, key=lambda x: x['name']).
itemgetter: The Workhorse
itemgetter creates callable objects that fetch items by index or key:
from operator import itemgetter
# Single item
get_first = itemgetter(0)
get_first(['a', 'b', 'c']) # 'a'
# Multiple items
get_ends = itemgetter(0, -1)
get_ends(['a', 'b', 'c', 'd']) # ('a', 'd')
# Dictionary keys
get_name = itemgetter('name')
user = {'name': 'Alice', 'age': 30}
get_name(user) # 'Alice'The real power shows up with sorted, max, and min:
from operator import itemgetter
users = [
{'name': 'Alice', 'age': 30, 'score': 95},
{'name': 'Bob', 'age': 25, 'score': 87},
{'name': 'Charlie', 'age': 35, 'score': 92},
]
# Sort by age
sorted(users, key=itemgetter('age'))
# [Bob(25), Alice(30), Charlie(35)]
# Sort by multiple fields (age, then name)
records = [('a', 2), ('b', 1), ('a', 1)]
sorted(records, key=itemgetter(1, 0))
# [('a', 1), ('b', 1), ('a', 2)]
# Find highest scorer
max(users, key=itemgetter('score')) # Aliceattrgetter: For Objects
When you're working with objects instead of dicts, use attrgetter:
from operator import attrgetter
class User:
def __init__(self, name, age, score):
self.name = name
self.age = age
self.score = score
def __repr__(self):
return f"User({self.name}, {self.age})"
users = [
User('Alice', 30, 95),
User('Bob', 25, 87),
User('Charlie', 35, 92),
]
# Sort by attribute
sorted(users, key=attrgetter('age'))
# [User(Bob, 25), User(Alice, 30), User(Charlie, 35)]
# Get multiple attributes
get_name_age = attrgetter('name', 'age')
get_name_age(users[0]) # ('Alice', 30)Nested attributes work too:
from operator import attrgetter
class Address:
def __init__(self, city, country):
self.city = city
self.country = country
class User:
def __init__(self, name, address):
self.name = name
self.address = address
user = User('Alice', Address('NYC', 'USA'))
get_city = attrgetter('address.city')
get_city(user) # 'NYC'methodcaller: Calling Methods
methodcaller creates a callable that calls a method on its argument:
from operator import methodcaller
# Call method with no args
upper = methodcaller('upper')
upper('hello') # 'HELLO'
# Call method with args
split_comma = methodcaller('split', ',')
split_comma('a,b,c') # ['a', 'b', 'c']
# Practical: case-insensitive sort
words = ['Banana', 'apple', 'Cherry']
sorted(words, key=methodcaller('lower'))
# ['apple', 'Banana', 'Cherry']Using with map and filter
These functions shine with functional programming patterns:
from operator import itemgetter, methodcaller
# Extract field from list of dicts
users = [{'name': 'Alice'}, {'name': 'Bob'}]
names = list(map(itemgetter('name'), users))
# ['Alice', 'Bob']
# Transform strings
words = ['hello', 'world']
upper_words = list(map(methodcaller('upper'), words))
# ['HELLO', 'WORLD']
# Chain transformations
data = [{'name': 'alice'}, {'name': 'bob'}]
result = list(map(methodcaller('upper'), map(itemgetter('name'), data)))
# ['ALICE', 'BOB']Using with functools.reduce()
The operator module really shines with functools.reduce(). Instead of writing lambdas for basic operations, use the built-in operator functions:
from functools import reduce
from operator import add, mul, or_, and_, concat
# Sum a list (without sum())
numbers = [1, 2, 3, 4, 5]
reduce(add, numbers) # 15
# Product of all elements
reduce(mul, numbers) # 120
# Flatten nested lists
nested = [[1, 2], [3, 4], [5, 6]]
reduce(concat, nested) # [1, 2, 3, 4, 5, 6]
# Combine boolean flags
flags = [True, True, False, True]
reduce(and_, flags) # False (all must be True)
reduce(or_, flags) # True (any True)You can also use operator.getitem for nested data access:
from functools import reduce
from operator import getitem
data = {'users': {'admin': {'name': 'Alice'}}}
# Navigate nested dicts
def deep_get(d, keys):
return reduce(getitem, keys, d)
deep_get(data, ['users', 'admin', 'name']) # 'Alice'Comparison with lambdas:
from functools import reduce
from operator import add, mul
numbers = [1, 2, 3, 4, 5]
# Lambda version (slower, harder to read)
reduce(lambda x, y: x + y, numbers)
reduce(lambda x, y: x * y, numbers)
# operator version (faster, clearer intent)
reduce(add, numbers)
reduce(mul, numbers)Why operator Over Lambda?
Three reasons I prefer operator functions:
- Faster: Implemented in C, not interpreted
- Picklable: Lambdas can't be serialized for multiprocessing
- Clearer intent: The name tells you what it does
from operator import itemgetter
import pickle
# Lambda fails
try:
pickle.dumps(lambda x: x['name'])
except Exception as e:
print(f"Lambda: {type(e).__name__}") # AttributeError
# itemgetter works
pickled = pickle.dumps(itemgetter('name'))
print("itemgetter: OK")Custom Operators: Dunder Methods
Now let's talk about the other side: making your own classes work with operators. This is where dunder methods come in.
The Basic Operators
Every operator has a corresponding dunder method:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
# Addition: +
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
# Subtraction: -
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
# Multiplication: *
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
# Equality: ==
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# Less than: <
def __lt__(self, other):
# Compare by magnitude
return (self.x**2 + self.y**2) < (other.x**2 + other.y**2)
v1 = Vector(1, 2)
v2 = Vector(3, 4)
v1 + v2 # Vector(4, 6)
v2 - v1 # Vector(2, 2)
v1 * 3 # Vector(3, 6)
v1 == v1 # True
v1 < v2 # True (magnitude 2.24 < 5)Reverse Operators: When the Left Side Doesn't Know
What happens with 3 * Vector(1, 2)? Python tries int.__mul__(3, Vector(1, 2)) first, which returns NotImplemented. Then it tries Vector.__rmul__(Vector(1, 2), 3):
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
# Called when scalar * vector
return self.__mul__(scalar)
v = Vector(1, 2)
v * 3 # Vector(3, 6) - uses __mul__
3 * v # Vector(3, 6) - uses __rmul__Every operator has a reverse version: __radd__, __rsub__, __rmul__, etc.
In-Place Operators
For mutable objects, implement in-place operators that modify and return self:
class Counter:
def __init__(self, value=0):
self.value = value
def __repr__(self):
return f"Counter({self.value})"
def __add__(self, other):
# Return new object
return Counter(self.value + other)
def __iadd__(self, other):
# Modify in place, return self
self.value += other
return self
c = Counter(5)
c += 3 # Uses __iadd__, modifies c
print(c) # Counter(8)
c2 = c + 2 # Uses __add__, creates new Counter
print(c, c2) # Counter(8) Counter(10)Comparison Operators
Python provides functools.total_ordering to reduce boilerplate:
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor, patch):
self.major = major
self.minor = minor
self.patch = patch
def __repr__(self):
return f"v{self.major}.{self.minor}.{self.patch}"
def __eq__(self, other):
return (self.major, self.minor, self.patch) == \
(other.major, other.minor, other.patch)
def __lt__(self, other):
return (self.major, self.minor, self.patch) < \
(other.major, other.minor, other.patch)
# Now we get __le__, __gt__, __ge__ for free
v1 = Version(1, 0, 0)
v2 = Version(1, 2, 0)
v3 = Version(2, 0, 0)
v1 < v2 # True
v2 <= v2 # True (auto-generated)
v3 >= v1 # True (auto-generated)
sorted([v3, v1, v2]) # [v1.0.0, v1.2.0, v2.0.0]The Container Operators
Make your class work like a sequence or mapping:
class Matrix:
def __init__(self, rows):
self._data = [list(row) for row in rows]
def __repr__(self):
return f"Matrix({self._data})"
# len(matrix)
def __len__(self):
return len(self._data)
# matrix[i] and matrix[i, j]
def __getitem__(self, key):
if isinstance(key, tuple):
row, col = key
return self._data[row][col]
return self._data[key]
# matrix[i] = value
def __setitem__(self, key, value):
if isinstance(key, tuple):
row, col = key
self._data[row][col] = value
else:
self._data[key] = list(value)
# del matrix[i]
def __delitem__(self, key):
del self._data[key]
# x in matrix (checks if row exists)
def __contains__(self, item):
return item in self._data
# for row in matrix
def __iter__(self):
return iter(self._data)
m = Matrix([[1, 2], [3, 4], [5, 6]])
len(m) # 3
m[0] # [1, 2]
m[1, 1] # 4
m[0, 0] = 10 # Set element
[1, 2] in m # False (original row changed)
[10, 2] in m # TrueThe Callable Operator
Make instances callable with __call__:
class Adder:
def __init__(self, value):
self.value = value
def __call__(self, x):
return self.value + x
add_five = Adder(5)
add_five(10) # 15
add_five(20) # 25
# Useful for stateful functions
class Counter:
def __init__(self):
self.count = 0
def __call__(self):
self.count += 1
return self.count
counter = Counter()
counter() # 1
counter() # 2
counter() # 3Operator Overloading Best Practices
After experimenting with these patterns, here's what I've learned:
1. Match Mathematical Expectations
If you overload +, it should behave like addition. Don't surprise users:
# Good: + combines vectors mathematically
class Vector:
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
# Bad: + doing something unexpected
class BadList:
def __add__(self, other):
# Interleaves instead of concatenates - confusing!
result = []
for a, b in zip(self.items, other.items):
result.extend([a, b])
return BadList(result)2. Return NotImplemented, Don't Raise
When you can't handle an operation, return NotImplemented so Python can try the other operand:
class Money:
def __init__(self, amount, currency):
self.amount = amount
self.currency = currency
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented # Let Python try other.__radd__
if self.currency != other.currency:
return NotImplemented # Can't add different currencies
return Money(self.amount + other.amount, self.currency)
def __radd__(self, other):
# Handle sum() which starts with 0
if other == 0:
return self
return NotImplemented3. Make Related Operators Consistent
If you implement __eq__, consider __hash__. If you implement __lt__, consider total_ordering:
from functools import total_ordering
@total_ordering
class Task:
def __init__(self, name, priority):
self.name = name
self.priority = priority
def __eq__(self, other):
if not isinstance(other, Task):
return NotImplemented
return self.priority == other.priority
def __lt__(self, other):
if not isinstance(other, Task):
return NotImplemented
return self.priority < other.priority
def __hash__(self):
# If __eq__ is defined, __hash__ should be too (or set to None)
return hash(self.priority)4. Type Check with isinstance
Always check types to avoid AttributeError surprises:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
# Type check!
if isinstance(other, Point):
return Point(self.x + other.x, self.y + other.y)
elif isinstance(other, (int, float)):
return Point(self.x + other, self.y + other)
return NotImplemented5. Document Non-Obvious Behavior
class Interval:
"""Represents a numeric interval [start, end].
Operators:
+ : Extends interval by amount (shifts both bounds)
| : Union of intervals (smallest start, largest end)
& : Intersection of intervals
in : Contains check for values
"""
def __init__(self, start, end):
self.start = min(start, end)
self.end = max(start, end)
def __add__(self, amount):
"""Shift interval by amount."""
return Interval(self.start + amount, self.end + amount)
def __or__(self, other):
"""Union: smallest enclosing interval."""
return Interval(
min(self.start, other.start),
max(self.end, other.end)
)
def __and__(self, other):
"""Intersection: overlapping region."""
start = max(self.start, other.start)
end = min(self.end, other.end)
if start <= end:
return Interval(start, end)
return None # No overlap
def __contains__(self, value):
"""Check if value is in interval."""
return self.start <= value <= self.endCommon Patterns
Pattern 1: Fluent Interface with or
Django-style query building:
class Query:
def __init__(self, filters=None):
self.filters = filters or []
def __or__(self, other):
return Query(self.filters + other.filters)
def __and__(self, other):
# Different combination strategy
return Query([f"({' AND '.join(self.filters + other.filters)})"])
@classmethod
def filter(cls, condition):
return Query([condition])
# Usage
q = Query.filter("age > 18") | Query.filter("status = 'active'")
# Combines filtersPattern 2: Numeric Type with Units
class Meters:
def __init__(self, value):
self.value = value
def __repr__(self):
return f"{self.value}m"
def __add__(self, other):
if isinstance(other, Meters):
return Meters(self.value + other.value)
return NotImplemented
def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Meters(self.value * scalar)
return NotImplemented
__rmul__ = __mul__
def __truediv__(self, other):
if isinstance(other, Meters):
return self.value / other.value # Dimensionless
if isinstance(other, (int, float)):
return Meters(self.value / other)
return NotImplemented
distance = Meters(100)
distance + Meters(50) # 150m
distance * 2 # 200m
2 * distance # 200m (uses __rmul__)
distance / Meters(50) # 2.0 (ratio)Pattern 3: Collection Wrapper
class DataSet:
def __init__(self, items):
self._items = list(items)
def __repr__(self):
return f"DataSet({self._items})"
# Arithmetic on all items
def __add__(self, scalar):
return DataSet(x + scalar for x in self._items)
def __mul__(self, scalar):
return DataSet(x * scalar for x in self._items)
# Container behavior
def __len__(self):
return len(self._items)
def __getitem__(self, index):
return self._items[index]
def __iter__(self):
return iter(self._items)
data = DataSet([1, 2, 3, 4, 5])
data + 10 # DataSet([11, 12, 13, 14, 15])
data * 2 # DataSet([2, 4, 6, 8, 10])
len(data) # 5
data[0] # 1Quick Reference: Operator → Dunder Method
| Operator | Method | Reverse | In-Place |
|---|---|---|---|
+ | __add__ | __radd__ | __iadd__ |
- | __sub__ | __rsub__ | __isub__ |
* | __mul__ | __rmul__ | __imul__ |
/ | __truediv__ | __rtruediv__ | __itruediv__ |
// | __floordiv__ | __rfloordiv__ | __ifloordiv__ |
% | __mod__ | __rmod__ | __imod__ |
** | __pow__ | __rpow__ | __ipow__ |
== | __eq__ | - | - |
!= | __ne__ | - | - |
< | __lt__ | - | - |
<= | __le__ | - | - |
> | __gt__ | - | - |
>= | __ge__ | - | - |
[] | __getitem__ | - | __setitem__ |
in | __contains__ | - | - |
() | __call__ | - | - |
len() | __len__ | - | - |
& | __and__ | __rand__ | __iand__ |
| | __or__ | __ror__ | __ior__ |
^ | __xor__ | __rxor__ | __ixor__ |
-x | __neg__ | - | - |
+x | __pos__ | - | - |
~x | __invert__ | - | - |
Wrapping Up
The operator module and custom operators serve different purposes but complement each other:
-
operator module: Use
itemgetter,attrgetter,methodcallerfor functional programming patterns. Cleaner than lambdas, faster, and picklable. -
Custom operators: Implement dunder methods to make your classes feel native. Follow mathematical conventions, return
NotImplementedfor unsupported operations, and usetotal_orderingfor comparisons.
Start with the basics (__add__, __eq__, __lt__) and add more as needed. The goal is making your code more readable, not showing off every operator you can overload.