Python functools Deep Dive: Beyond lru_cache
Explore the functools module beyond caching. Learn partial, reduce, singledispatch, cached_property, and total_ordering with practical examples.
What you'll learn
- ✓Using partial to create specialized functions from general ones
- ✓Implementing function overloading with singledispatch
- ✓Leveraging cached_property for expensive computed attributes
- ✓Writing comparable classes with total_ordering
Prerequisites
None — this post is self-contained.
Most Python developers know functools.lru_cache. But the functools module contains a collection of higher-order functions and utilities that solve real problems elegantly. This article covers the ones you should know beyond basic caching.
functools.partial — Fix Some Arguments
partial creates a new function with some arguments pre-filled. This is cleaner than writing a lambda or a wrapper function.
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(3)) # 27
Practical Use: Configuring Callbacks
partial shines when you need to pass a callback that expects fewer arguments than you have context for:
from functools import partial
import logging
def log_event(level, component, message):
logging.log(level, f"[{component}] {message}")
# Create specialized loggers
db_warning = partial(log_event, logging.WARNING, "database")
api_error = partial(log_event, logging.ERROR, "api")
db_warning("Connection pool exhausted")
api_error("Timeout on /users endpoint")
partial vs lambda
Both work, but partial is more inspectable and picklable:
from functools import partial
# Lambda
f = lambda x: power(x, 2)
print(f) # <function <lambda> at 0x...>
# Partial
g = partial(power, exponent=2)
print(g) # functools.partial(<function power>, exponent=2)
print(g.func) # <function power>
print(g.keywords) # {'exponent': 2}
functools.singledispatch — Function Overloading
Python does not support function overloading by type signature like Java does. singledispatch gives you the next best thing: dispatch based on the type of the first argument.
from functools import singledispatch
@singledispatch
def format_value(value) -> str:
return str(value)
@format_value.register(int)
def _(value: int) -> str:
return f"{value:,}"
@format_value.register(float)
def _(value: float) -> str:
return f"{value:.2f}"
@format_value.register(list)
def _(value: list) -> str:
return f"[{len(value)} items]"
print(format_value(1000000)) # "1,000,000"
print(format_value(3.14159)) # "3.14"
print(format_value([1, 2, 3])) # "[3 items]"
print(format_value("hello")) # "hello" (falls through to base)
singledispatchmethod for Classes
For methods, use singledispatchmethod:
from functools import singledispatchmethod
class Formatter:
@singledispatchmethod
def format(self, value) -> str:
return str(value)
@format.register(dict)
def _(self, value: dict) -> str:
pairs = ", ".join(f"{k}={v}" for k, v in value.items())
return f"{{{pairs}}}"
functools.reduce — Accumulate a Sequence
reduce applies a two-argument function cumulatively to sequence elements. It was a builtin in Python 2 and moved to functools in Python 3.
from functools import reduce
from operator import mul
# Product of a list
numbers = [2, 3, 4, 5]
product = reduce(mul, numbers)
print(product) # 120
# Flatten nested lists
nested = [[1, 2], [3, 4], [5, 6]]
flat = reduce(lambda acc, lst: acc + lst, nested, [])
print(flat) # [1, 2, 3, 4, 5, 6]
# Build a nested dictionary access
def deep_get(d, *keys):
return reduce(lambda obj, key: obj[key], keys, d)
config = {"db": {"primary": {"host": "localhost", "port": 5432}}}
print(deep_get(config, "db", "primary", "host")) # "localhost"
Use reduce sparingly. A for loop is often clearer. But for operations like computing products or composing functions, it is the right tool.
functools.cached_property — Lazy Computed Attributes
cached_property computes a value once and caches it as an instance attribute. Unlike @property with manual caching, it is thread-safe and clean.
from functools import cached_property
import time
class DataAnalyzer:
def __init__(self, data: list[float]):
self._data = data
@cached_property
def statistics(self) -> dict:
"""Expensive computation done only once."""
time.sleep(1) # Simulate heavy work
n = len(self._data)
mean = sum(self._data) / n
variance = sum((x - mean) ** 2 for x in self._data) / n
return {
"mean": mean,
"variance": variance,
"std_dev": variance ** 0.5,
"count": n,
}
analyzer = DataAnalyzer([1.0, 2.0, 3.0, 4.0, 5.0])
print(analyzer.statistics) # Takes 1 second
print(analyzer.statistics) # Instant -- cached
To invalidate the cache, delete the attribute:
del analyzer.statistics # Next access recomputes
Note: cached_property only works on instances with a __dict__. It will not work with __slots__.
functools.total_ordering — Complete Comparison Methods
If your class defines __eq__ and one of __lt__, __le__, __gt__, or __ge__, total_ordering fills in the rest:
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major: int, minor: int, patch: int):
self.major = major
self.minor = minor
self.patch = patch
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) == \
(other.major, other.minor, other.patch)
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) < \
(other.major, other.minor, other.patch)
def __repr__(self):
return f"Version({self.major}.{self.minor}.{self.patch})"
v1 = Version(1, 2, 0)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 0)
print(v1 < v2) # True
print(v1 >= v3) # True (generated by total_ordering)
print(v2 <= v1) # False (generated by total_ordering)
print(sorted([v2, v1, v3])) # [Version(1.2.0), Version(1.2.0), Version(1.3.0)]
functools.wraps — Preserve Function Metadata
When writing decorators, wraps copies the decorated function’s name, docstring, and other metadata onto the wrapper:
from functools import wraps
import time
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_function():
"""Does something slowly."""
time.sleep(0.1)
print(slow_function.__name__) # "slow_function", not "wrapper"
print(slow_function.__doc__) # "Does something slowly."
Without @wraps, debugging and documentation tools see “wrapper” instead of the original function name.
Key Takeaways
The functools module gives you battle-tested tools for common functional programming patterns: fixing arguments with partial, dispatching by type with singledispatch, lazy computation with cached_property, and painless comparison with total_ordering. Before writing custom boilerplate, check whether functools already solves your problem.
Related articles
- Python Python functools: partial, lru_cache, reduce, and More
Master Python's functools module with practical examples of partial, lru_cache, reduce, singledispatch, cached_property, and wraps.
- Python Python collections Module: The Data Structures You Are Missing
Master Python's collections module with defaultdict, Counter, deque, namedtuple, and OrderedDict through practical, real-world examples.
- Python Python itertools Recipes: Practical Patterns for Efficient Iteration
Master Python's itertools module with practical recipes for grouping, windowing, chaining, and processing iterables efficiently.
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.