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'))  # Alice

attrgetter: 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:

  1. Faster: Implemented in C, not interpreted
  2. Picklable: Lambdas can't be serialized for multiprocessing
  3. 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    # True

The 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()  # 3

Operator 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 NotImplemented

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 NotImplemented

5. 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.end

Common 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 filters

Pattern 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]          # 1

Quick Reference: Operator → Dunder Method

OperatorMethodReverseIn-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, methodcaller for functional programming patterns. Cleaner than lambdas, faster, and picklable.

  • Custom operators: Implement dunder methods to make your classes feel native. Follow mathematical conventions, return NotImplemented for unsupported operations, and use total_ordering for 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.

React to this post: