Write Less, Reuse More: A Practical Deep Dive into Python's functools
Learn how Python's functools module helps you cache expensive calls, freeze arguments, dispatch on type, and write decorators that behave — with runnable examples and the pitfalls to avoid.
Some of the most useful tools in Python aren't language keywords at all — they're small, sharp functions that take other functions and give you back something better. That's exactly what the functools module in the standard library is: a toolbox of higher-order helpers for caching results, pre-filling arguments, dispatching on type, and writing decorators that don't quietly break your code. None of it requires third-party packages, and most of it is one import away.
This guide walks through the pieces of functools you'll actually reach for, with runnable examples and the pitfalls that bite people in production. If you've ever hand-rolled a memoization dictionary or wondered why your decorated function lost its name, this is for you.
Caching with lru_cache and cache
The single most popular member of functools is lru_cache. Decorate a function with it and Python stores the return value for each set of arguments, so repeated calls with the same inputs skip the work entirely. The classic demonstration is a naive recursive Fibonacci, which is exponential without caching and linear with it.
import functools
@functools.lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(30)) # 832040
print(fib.cache_info()) # CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
The maxsize argument controls how many results are kept; when the cache is full, the least-recently-used entry is evicted (hence the name). Pass maxsize=None for an unbounded cache, or a number like 128 to cap memory. Every cached function also gains cache_info() for hit/miss statistics and cache_clear() to reset it.
Since Python 3.9 there's a simpler alias, functools.cache, which is just lru_cache(maxsize=None) without the eviction bookkeeping:
@functools.cache
def square(n):
return n * n
square(5) # computed
square(5) # returned instantly from cache
Pitfalls. The cached function's arguments must be hashable, so you can pass integers, strings, and tuples but not lists or dicts. The cache lives for the life of the process, which means caching a method on self can keep whole objects alive longer than you expect and cause memory leaks. And never cache a function whose result depends on the clock, a database, or random state — you'll happily serve stale answers forever.
Freezing arguments with partial
partial takes a function and some arguments, and returns a new callable with those arguments already filled in. It's a clean alternative to writing throwaway lambdas or wrapper functions when you just want to specialize something.
from functools import partial
def power(base, exp):
return base ** exp
cube = partial(power, exp=3)
print(cube(4)) # 64
# A very common real-world use: fix a keyword argument
basetwo = partial(int, base=2)
print(basetwo("1010")) # 10
Partials shine when you hand a callback to another API — a GUI button, a sorted key, a map call — and need to bake in configuration up front. Because a partial object stores its func, args, and keywords as inspectable attributes, it's also easier to debug than an anonymous lambda.
Folding a sequence with reduce
reduce applies a two-argument function cumulatively across an iterable, collapsing it to a single value. Summing and multiplying are the textbook cases, though for plain sums you should prefer the built-in sum.
from functools import reduce
product = reduce(lambda a, b: a * b, [1, 2, 3, 4, 5])
print(product) # 120
# Always supply an initializer when the iterable might be empty
print(reduce(lambda a, b: a * b, [], 1)) # 1
The third argument is an initial value. It's not just a safety net for empty iterables — it also sets the starting accumulator, which matters when the accumulator type differs from the elements. Reach for reduce sparingly: a readable for loop or a comprehension is usually clearer, and the community generally reserves reduce for genuinely associative folds.
Writing well-behaved decorators with wraps
When you write a decorator, the inner wrapper function replaces the original — and with it goes the original's name, docstring, and signature metadata. That breaks introspection, documentation tools, and debuggers. functools.wraps copies that metadata across so the decorated function still looks like itself.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name):
"Return a friendly greeting."
return f"hi {name}"
print(greet.__name__) # greet (not 'wrapper')
print(greet.__doc__) # Return a friendly greeting.
Without @functools.wraps(func), that first print would say wrapper and the docstring would be None. Treat wraps as mandatory boilerplate in every decorator you write — it costs one line and saves real confusion later.
Dispatching on type with singledispatch
Sometimes you want one function to behave differently depending on the type of its first argument, without a tangle of isinstance checks. singledispatch turns a plain function into a generic function you can extend, one registered implementation per type.
from functools import singledispatch
@singledispatch
def describe(obj):
return f"object: {obj!r}"
@describe.register
def _(obj: int):
return f"int: {obj}"
@describe.register(list)
def _(obj):
return f"list of {len(obj)}"
print(describe(3)) # int: 3
print(describe([1, 2])) # list of 2
print(describe("x")) # object: 'x' (falls back to the base)
The undecorated function is the fallback, used when no registered type matches. You can register implementations either by annotating the first parameter or by passing the type explicitly to register. There's also singledispatchmethod for doing the same thing on class methods.
Caching a property with cached_property
cached_property computes an attribute the first time it's accessed and then stores the result on the instance, so subsequent access is a plain attribute lookup. It's ideal for expensive derived values that don't change over an object's lifetime.
import functools
class Dataset:
def __init__(self, n):
self.n = n
@functools.cached_property
def total(self):
print("computing total...")
return sum(range(self.n))
d = Dataset(1000)
print(d.total) # prints "computing total..." then 499500
print(d.total) # 499500, no recompute
Because the value is stored in the instance's __dict__, this only works on classes that have one — it won't work with __slots__ unless you make room for it. If the underlying data can change, delete the attribute with del d.total to force a recomputation on next access.
Filling in comparisons with total_ordering
Implementing all six rich-comparison methods (__lt__, __le__, __gt__, __ge__, __eq__, __ne__) by hand is tedious and error-prone. The total_ordering class decorator lets you define just __eq__ and one ordering method, and it derives the rest.
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, number):
self.number = number
def __eq__(self, other):
return self.number == other.number
def __lt__(self, other):
return self.number < other.number
print(Version(1) < Version(2)) # True
print(Version(2) >= Version(2)) # True
print(Version(3) > Version(1)) # True
It's a small convenience with a caveat: the generated methods add a layer of indirection, so if comparison is in a hot loop and you've profiled it as a bottleneck, defining the methods explicitly can be marginally faster. For the vast majority of classes, the readability win is worth it.
Wrap-up and next steps
functools is a compact module, but it removes a surprising amount of boilerplate: lru_cache and cache for memoization, partial for specializing callables, reduce for folds, wraps for honest decorators, singledispatch for type-based behavior, cached_property for lazy attributes, and total_ordering for comparisons. Learning these means you'll stop reinventing them.
A good next step is to open the official functools documentation and read it end to end — it's short — then audit your own codebase for hand-rolled caches and repetitive comparison methods you can replace. Pair this with the itertools module and you'll have most of Python's functional toolkit at your fingertips.