Python functools: 6 Decorators and Utilities That Will Simplify Your Code
If you've been writing Python for a while, you've probably used @staticmethod or @property. But Python's standard library has a hidden gem — the functools module — that gives you even more powerful tools for writing cleaner, faster, and more maintainable code.
In my last few articles, I covered itertools and the collections module. Today we're tackling functools — the module every Python developer should know but many don't reach for often enough.
Let's dive into the six functools tools I use most in production code.
1. @lru_cache — Memoization Made Trivial
The single most useful functools utility. @lru_cache stores the results of expensive function calls and returns the cached result when the same arguments occur again.
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# First call computes, subsequent calls are O(1)
print(fibonacci(100)) # 354224848179261915075 — nearly instant
Without lru_cache, fibonacci(100) would never finish. With it, each unique n is computed once. The maxsize parameter controls how many results to keep (use maxsize=None for unlimited caching).
Real-world use case: Expensive database lookups
@lru_cache(maxsize=256)
def get_user_permissions(user_id: int) -> list[str]:
"""Expensive DB/compute call — cached aggressively."""
result = db.query("SELECT permission FROM user_permissions WHERE user_id = ?", user_id)
return [row[0] for row in result]
cache_info() gives you hit/miss stats:
print(fibonacci.cache_info())
# CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)
Pro tip: Use cache_clear() for testing, or when cached data goes stale.
2. @cache — The Simpler Sibling (Python 3.9+)
Python 3.9 introduced @cache as a shorthand for @lru_cache(maxsize=None):
from functools import cache
@cache
def load_config(filepath: str) -> dict:
with open(filepath) as f:
return json.load(f)
Same behavior as lru_cache, but unlimited. Use this when you know the input domain is small or bounded.
3. @singledispatch — Clean Function Overloading
Python doesn't have method overloading like Java, but @singledispatch gives you the next best thing — single-dispatch generic functions.
from functools import singledispatch
@singledispatch
def process(data):
"""Default handler — raises for unknown types."""
raise TypeError(f"No handler for {type(data).__name__}")
@process.register(str)
def _(data: str):
return f"String: {data.upper()}"
@process.register(list)
def _(data: list):
return [item * 2 for item in data]
@process.register(dict)
def _(data: dict):
return {k: v * 3 for k, v in data.items()}
# Usage
print(process("hello")) # String: HELLO
print(process([1, 2, 3])) # [2, 4, 6]
print(process({"a": 1})) # {'a': 3}
This is much cleaner than a chain of isinstance() checks and makes it easy to add support for new types without modifying existing code.
When to use it:
- Processing different data formats (JSON, CSV, XML)
- Serialization/deserialization pipelines
- Plugin-like architectures where different types need different handling
4. @wraps — Preserving Function Metadata
Ever decorated a function and lost its name, docstring, or signature? That's where @wraps comes in.
from functools import wraps
def retry(max_attempts: int = 3):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
print(f"Attempt {attempt + 1} failed: {e}")
return None
return wrapper
return decorator
@retry(max_attempts=5)
def fetch_data(url: str) -> dict:
"""Fetch JSON data from a URL with retry logic."""
...
print(fetch_data.__name__) # 'fetch_data' — preserved!
print(fetch_data.__doc__) # 'Fetch JSON data from a URL with retry logic.'
Without @wraps, fetch_data.__name__ would return 'wrapper' and you'd lose introspection. Always use @wraps in your decorators — it's cheap and prevents confusing bugs.
5. partial — Freeze Function Arguments
partial lets you "pre-fill" arguments of a function, creating a new callable with fewer parameters.
from functools import partial
def power(base: float, exponent: float) -> float:
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(5)) # 125
Practical example: Configurable loggers
import logging
def create_logger(name: str, level: int = logging.INFO):
logger = logging.getLogger(name)
logger.setLevel(level)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
))
logger.addHandler(handler)
return logger
# Pre-configure loggers for different modules
api_logger = partial(create_logger, "api", level=logging.DEBUG)
db_logger = partial(create_logger, "database", level=logging.WARNING)
worker_logger = partial(create_logger, "worker")
# When you need them:
log = api_logger()
log.debug("This appears in API logs")
Combined with map:
# Instead of a lambda:
results = map(lambda x: power(x, 2), range(10))
# Use partial:
results = map(partial(power, exponent=2), range(10))
partial is often cleaner and more readable than a lambda, especially when used as a callback or in functional pipelines.
6. reduce — Accumulate Values Sequentially
reduce was moved to functools in Python 3 (it was a built-in in Python 2). It applies a function cumulatively to items in a sequence, reducing it to a single value.
from functools import reduce
# Multiply all numbers in a list
numbers = [1, 2, 3, 4, 5]
product = reduce(lambda a, b: a * b, numbers)
print(product) # 120
Practical: Flatten nested structures
def merge_dicts(a: dict, b: dict) -> dict:
"""Merge two dicts, summing overlapping values."""
result = a.copy()
for key, value in b.items():
result[key] = result.get(key, 0) + value
return result
transactions = [
{"apple": 3, "banana": 2},
{"apple": 1, "orange": 5},
{"banana": 4, "orange": 1},
]
total = reduce(merge_dicts, transactions)
print(total) # {'apple': 4, 'banana': 6, 'orange': 6}
When NOT to use reduce:
Sometimes a simple loop is more readable. David Beazley once said: "reduce is fine, but 90% of the time an explicit for loop is better." Use your judgment.
Bonus: total_ordering — Complete Your Comparison Operators
Writing __eq__ and all six comparison operators (__lt__, __le__, __gt__, __ge__) is tedious. @total_ordering fills in the gaps if you define just __eq__ and one other:
from functools import total_ordering
@total_ordering
class Priority:
def __init__(self, level: int):
self.level = level
def __eq__(self, other):
return self.level == other.level
def __lt__(self, other):
return self.level < other.level
# Now all comparisons work:
p1 = Priority(1)
p2 = Priority(2)
print(p1 <= p2) # True — auto-generated
print(p1 >= p2) # False — auto-generated
This reduces boilerplate without magic — functools generates the missing operators for you.
Putting It All Together
Here's a production example combining multiple functools utilities:
from functools import cache, wraps, partial
from typing import Any
import time
def timed(func):
"""Decorator that logs execution time."""
@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:.3f}s")
return result
return wrapper
@cache
def get_config() -> dict:
"""Load app config from file (cached after first call)."""
with open("config.json") as f:
return json.load(f)
@timed
def compute_discounts(prices: list[float], rate: float) -> list[float]:
discount = partial(lambda p, r: p * (1 - r), rate=rate)
return list(map(discount, prices))
# Clean, readable, fast — all thanks to functools
Cheat Sheet
| Function | Purpose | Python Version |
|---|---|---|
@lru_cache |
Memoization with size limit | 3.2+ |
@cache |
Unlimited memoization | 3.9+ |
@singledispatch |
Type-based dispatch | 3.4+ |
@wraps |
Preserve metadata in decorators | 3.2+ |
partial |
Freeze function arguments | 3.2+ |
reduce |
Sequential accumulation | 3.2+ (moved to functools) |
@total_ordering |
Auto-generate comparison ops | 3.2+ |
What's Next?
If you found this useful, you might also enjoy my guide on the collections module or the itertools deep dive.
And if you're writing Python decorators or functional code regularly, I've put together a collection of Python automation patterns and AI coding prompts that covers these patterns and many more in ready-to-use templates.
What's your favorite functools utility? I'm partial to lru_cache — it's solved more performance problems for me than any other single line of code.
Top comments (0)