Mastering Python's functools: Caching, Partials, and Smarter Functions

A practical deep dive into Python's functools module — memoization with lru_cache and cache, cached_property, partial application, reduce, singledispatch, total_ordering, and writing decorators that don't lose metadata.

Mastering Python's functools: Caching, Partials, and Smarter Functions

Most Python developers reach for functools once — to grab lru_cache — and never explore the rest. That's a missed opportunity. The functools module is a small toolbox of higher-order helpers: utilities that take functions as input, return new functions, or otherwise make functions easier to compose and reuse. Used well, they remove boilerplate, speed up code dramatically, and make your intent clearer.

This guide walks through the pieces of functools you'll actually use in real projects, with runnable examples for each. Everything here works on Python 3.9+ unless noted.

Memoization with lru_cache and cache

Caching is the headline feature. @lru_cache wraps a function and stores results keyed by its arguments, so repeated calls with the same inputs return instantly instead of recomputing. The classic demonstration is a naive recursive Fibonacci, which is exponentially slow 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 caps how many results are retained; once full, the least recently used entry is evicted (hence "LRU"). Pass maxsize=None for an unbounded cache. Every wrapped function gains two helpers: 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) — an unbounded cache with less typing:

@functools.cache
def square(n):
    return n * n

print(square(12))  # 144

Pitfalls. Cached functions must take hashable arguments — you can't cache a function that takes a list or dict directly. The cache also holds references to every result, so an unbounded cache on a long-running process is a memory leak waiting to happen; prefer a finite maxsize for anything user-facing. And never cache a function whose output depends on external state (the current time, a database, random numbers) — you'll get stale answers.

cached_property: compute once, store on the instance

When an object has an expensive-to-compute attribute that doesn't change, cached_property turns a method into a lazy attribute: it runs the first time you access it, then caches the result on the instance for every subsequent access.

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 recomputation

Unlike @property, the value is stored in the instance's __dict__, so the computation happens exactly once per object. If the underlying data changes, you can force a recompute by deleting the attribute with del d.total.

partial: pre-filling arguments

functools.partial builds a new callable from an existing one with some arguments already supplied. It's a clean alternative to writing little wrapper lambdas, and the result is a real object you can name and reuse.

import functools

# Pre-bind a keyword argument
basetwo = functools.partial(int, base=2)
print(basetwo("1010"))  # 10

def power(base, exp):
    return base ** exp

cube = functools.partial(power, exp=3)
print(cube(4))  # 64

Partials shine when an API wants a zero-argument or single-argument callback — GUI button handlers, map, thread pools — and you need to "freeze" some configuration into it. They're more introspectable than lambdas too: a partial keeps .func, .args, and .keywords attributes you can inspect.

reduce: folding a sequence into one value

reduce applies a two-argument function cumulatively across an iterable, collapsing it to a single result. It lives in functools (it was moved out of builtins in Python 3).

from functools import reduce

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

# The optional third argument is a starting value — and the
# safe result for an empty iterable.
print(reduce(lambda a, b: a * b, [], 1))  # 1

A word of judgment: for sums use sum(), for joins use str.join(), and for most accumulations an explicit for loop reads better. Reach for reduce when the operation genuinely has no dedicated builtin and the fold is the clearest expression of intent.

wraps: decorators that don't lie about themselves

When you write a decorator, the inner wrapper function replaces the original — which means the decorated function loses its name, docstring, and signature metadata. functools.wraps copies that metadata across so your decorated functions still introspect correctly (important for documentation tools, debuggers, and frameworks that read __name__).

import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # ... timing logic would go here ...
        return func(*args, **kwargs)
    return wrapper

@timer
def greet(name):
    "Say hello."
    return f"Hi {name}"

print(greet.__name__)  # greet  (not "wrapper")
print(greet.__doc__)   # Say hello.

Make @functools.wraps(func) a reflex on every decorator you write. Without it, greet.__name__ would report "wrapper" and the docstring would vanish.

singledispatch: function overloading by type

Python doesn't have built-in function overloading, but @singledispatch gives you something close: a generic function whose behavior is chosen by the type of its first argument. Register type-specific implementations and the dispatcher picks the right one at call time.

import functools

@functools.singledispatch
def describe(arg):
    return f"generic: {arg!r}"

@describe.register
def _(arg: int):
    return f"int double: {arg * 2}"

@describe.register
def _(arg: list):
    return f"list of {len(arg)}"

print(describe("x"))        # generic: 'x'
print(describe(10))         # int double: 20
print(describe([1, 2, 3]))  # list of 3

This is far cleaner than a sprawling chain of isinstance checks, and it's extensible — other modules can register handlers for their own types without touching your original function. For dispatching on a method's argument inside a class, use the sibling singledispatchmethod.

total_ordering and cmp_to_key

To make a class fully comparable, you'd normally implement all of __lt__, __le__, __gt__, __ge__, and __eq__. The @total_ordering decorator fills in the rest if you provide __eq__ and just one ordering method:

import functools

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

    def _key(self):
        return (self.major, self.minor)

    def __eq__(self, other):
        return self._key() == other._key()

    def __lt__(self, other):
        return self._key() < other._key()

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

Finally, cmp_to_key bridges old-style comparison functions (which take two items and return a negative/zero/positive number) into the key= argument that modern sorted expects:

import functools

def cmp(a, b):
    return len(a) - len(b)

print(sorted(["ccc", "a", "bb"], key=functools.cmp_to_key(cmp)))
# ['a', 'bb', 'ccc']

Wrap-up and next steps

The functools module rewards a closer look. Use lru_cache/cache to memoize pure, expensive functions; cached_property for compute-once instance attributes; partial to specialize callables without lambdas; wraps on every decorator; singledispatch instead of type-checking ladders; and total_ordering to keep comparison code DRY.

A good next step is to profile a real hotspot in your own code and see whether a cache helps — and to read the official functools documentation, which is short and full of detail. Pair this with the standard-library iteration helpers in itertools and you'll have a powerful, expressive functional toolkit at your fingertips.