Mastering Python's functools: Caching, Partials, Dispatch, and Cleaner Decorators

A hands-on tour of Python's functools module—lru_cache and cache, partial, reduce, wraps, singledispatch, cached_property, and total_ordering—with runnable examples and the pitfalls that trip people up.

Mastering Python's functools: Caching, Partials, Dispatch, and Cleaner Decorators

Most Python developers reach for functools exactly once—when they need lru_cache—and never explore the rest. That's a shame, because the module is a compact toolkit of higher-order helpers that eliminate boilerplate, speed up hot code paths, and make your classes and decorators behave the way they should. In this deep dive we'll work through the pieces you'll actually use: memoization with lru_cache and cache, argument pre-binding with partial, folding with reduce, honest decorators with wraps, type-based dispatch with singledispatch, lazy attributes with cached_property, and free comparison operators with total_ordering. Every example below is runnable as-is.

Memoization: lru_cache and cache

The headline feature. Wrapping a function with @lru_cache stores results keyed by the arguments, so repeated calls with the same inputs return instantly. The classic demonstration is a naive recursive Fibonacci, which is exponential without a cache and linear with one.

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, computed almost instantly
print(fib.cache_info())   # CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
fib.cache_clear()         # wipe the cache when you need to

The maxsize argument bounds the cache using a least-recently-used eviction policy; pass maxsize=None for an unbounded cache. Every wrapped function gains cache_info() for hit/miss statistics and cache_clear() to reset it. Since Python 3.9 there's also functools.cache, a thin alias for lru_cache(maxsize=None) that reads more clearly when you don't need eviction.

@functools.cache
def factorial(n):
    return 1 if n <= 1 else n * factorial(n - 1)

print(factorial(10))  # 3628800

Pitfalls. Cached arguments must be hashable, so you can't pass a list or dict directly—convert to a tuple or frozenset first. Never cache a function that has side effects or depends on external state (the current time, a database, a file) because you'll get a stale answer forever. And be aware that the cache holds references to arguments and results, so an unbounded cache on a long-running process is a slow memory leak in disguise—prefer a finite maxsize in production.

partial: pre-filling arguments

functools.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.

from functools import partial

# int() takes a base; bind it to build a binary parser
to_binary = partial(int, base=2)
print(to_binary('10010'))   # 18

# Pre-configure a logging-style helper
def report(level, message):
    return f"[{level}] {message}"

warn = partial(report, "WARN")
print(warn("disk almost full"))   # [WARN] disk almost full

Positional arguments you supply are locked in from the left; keyword arguments become defaults you can still override at call time. partial shines when you need to pass a configured callback to something like map, sorted(key=...), or a GUI/event handler that expects a zero- or one-argument function.

reduce: folding a sequence into one value

reduce applies a two-argument function cumulatively across an iterable, collapsing it to a single result. Python moved it out of builtins into functools to nudge people toward explicit loops and comprehensions, but it's still the right tool for genuine folds.

from functools import reduce

product = reduce(lambda a, b: a * b, [1, 2, 3, 4, 5])
print(product)  # 120

# The third argument is an initializer / starting accumulator
flat = reduce(lambda acc, x: acc + [x * 2], [1, 2, 3], [])
print(flat)     # [2, 4, 6]

Reach for reduce when there's no built-in that already does the job. For sums use sum(), for min/max use min()/max()—they're clearer and faster. Save reduce for multiplication, custom combining logic, or merging dictionaries.

wraps: decorators that don't lie about themselves

When you write a decorator, the inner wrapper function replaces the original, so the decorated function loses its real name, docstring, and signature metadata. functools.wraps copies that metadata across, keeping introspection tools and documentation honest.

import functools

def logged(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logged
def greet(name):
    "Return a friendly greeting."
    return f"Hi {name}"

print(greet.__name__)  # greet  (without @wraps this prints 'wrapper')
print(greet.__doc__)   # Return a friendly greeting.

Always add @functools.wraps(func) to your wrappers. Without it, debuggers, help(), and frameworks that inspect function names (like pytest or many web routers) see the wrong information.

singledispatch: function overloading by type

singledispatch turns a plain function into a generic function whose implementation is chosen by the type of the first argument—Python's answer to type-based overloading without a wall of isinstance checks.

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)} items"

print(describe(5))          # int: 5
print(describe([1, 2, 3]))  # list of 3 items
print(describe("hello"))    # object: 'hello'  (falls back to base)

The base function is the fallback; each register adds a specialization. You can annotate the type on the argument (3.7+) or pass it explicitly to register. For dispatching on the type of a method's self-plus-first-argument inside a class, there's a matching singledispatchmethod.

cached_property: compute once, reuse forever

cached_property (Python 3.8+) turns an expensive method into an attribute that runs on first access and then caches the result on the instance, so subsequent reads are free.

import functools

class Dataset:
    def __init__(self, rows):
        self.rows = rows

    @functools.cached_property
    def total(self):
        print("computing total...")
        return sum(self.rows)

d = Dataset([1, 2, 3, 4])
print(d.total)  # prints "computing total..." then 10
print(d.total)  # just 10 — no recompute

Because the value is stored in the instance's __dict__, it persists for the object's lifetime. That's exactly what you want for derived data that never changes—but it means the class can't use __slots__ (there's no __dict__ to write into), and you should only cache genuinely immutable results. If the underlying data can change, use a regular property or invalidate the cache by del-ing the attribute.

total_ordering: full comparisons from two methods

Implementing all six rich-comparison methods is tedious. The total_ordering class decorator fills in the rest as long as you define __eq__ plus one of __lt__, __le__, __gt__, or __ge__.

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major, minor):
        self.major, self.minor = major, minor

    def __eq__(self, other):
        return (self.major, self.minor) == (other.major, other.minor)

    def __lt__(self, other):
        return (self.major, self.minor) < (other.major, other.minor)

print(Version(1, 2) < Version(1, 3))   # True
print(Version(2, 0) >= Version(1, 9))  # True  (derived automatically)

It's a small convenience with a small cost: the generated operators do a little extra work, so for performance-critical comparisons you may still want to hand-write them. For everyday value objects, it removes a lot of noise.

Wrap-up and next steps

functools rewards a second look. Reach for lru_cache or cache to memoize pure functions, partial to pre-configure callbacks, reduce for genuine folds, wraps in every decorator you write, singledispatch to replace type-switch ladders, cached_property for expensive derived attributes, and total_ordering to make your objects sortable with minimal code. The common thread is removing boilerplate so your intent stands out.

From here, skim the official functools documentation for the corners we didn't cover—reduce with operator functions, singledispatchmethod, and partialmethod—and try refactoring one place in your own codebase where a hand-rolled cache, a lambda-heavy callback, or a stack of isinstance checks could become a single, clearer functools call.