pyguides

functools: Functional Programming Utilities

The functools module is where Python keeps its higher-order functions and the plumbing for working with callables. If you’ve ever written functools functional code, memoized a recursive function, written a decorator that still shows the right name in tracebacks, or wanted sorted() to use a comparator, you’ve reached for functools without thinking about it.

Key Takeaways

  • @lru_cache and @cache memoize function results, with @cache being an unbounded, simpler alternative for small input spaces.
  • cached_property computes a value once per instance, ideal for expensive derived attributes.
  • partial and partialmethod pre-bind arguments; Placeholder (3.14) lets you leave slots open.
  • @wraps preserves function metadata in decorators; without it, help() and inspect.signature() break.
  • singledispatch enables type-based function dispatch on the first argument’s type.

This guide walks through the utilities you’ll actually use, in roughly the order you’ll meet them in real code: caching, partial application, decorator helpers, reduction, generic dispatch, and a couple of class-level tools. Every signature and “Added in version” note is cross-checked against the Python 3.14 docs.

How do you memoize functions with lru_cache and cache?

lru_cache memoizes a function’s results using a least-recently-used eviction policy. The classic demo is recursive Fibonacci, where naive recursion is exponential and @lru_cache makes it linear:

from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

[fib(n) for n in range(10)]
# output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

fib.cache_info()
# output: CacheInfo(hits=8, misses=10, maxsize=128, currsize=10)

A few details that bite people later:

  • maxsize is the size of the LRU queue. Power-of-two values perform best internally. Set it to None for an unbounded cache.
  • typed=False (the default) treats 1 and 1.0 as the same key, but a str and an int with the same value are still distinct. Different keyword-argument orders count as different keys.
  • Arguments must be hashable. You can’t cache on a list or dict.
  • The cache holds strong references to both the args and the return values, so don’t decorate something that returns large mutable objects unless you also plan the eviction.

When the input space is small and bounded, reach for cache (added in 3.9). It’s a thin wrapper over lru_cache(maxsize=None) with a smaller and faster code path because it never has to evict:

from functools import cache

@cache
def factorial(n):
    return n * factorial(n - 1) if n else 1

factorial(5)         # 120
factorial(5)         # 120  (cache hit, no new calls)
factorial.cache_info()
# output: CacheInfo(hits=1, misses=6, maxsize=None, currsize=6)

cache exposes the same cache_info(), cache_clear(), and __wrapped__ attributes as lru_cache. If you’re tempted to decorate a method, watch out: self becomes part of the cache key, and the cache keeps a reference to the instance for as long as the function lives. That leaks memory in long-running processes. For per-instance caching, use cached_property instead.

What is cached_property and when should you use it?

cached_property (3.8) computes a value on first attribute access, then stores it as a plain instance attribute. The result is that subsequent reads skip the computation entirely. It’s a good fit for expensive derived values that don’t change for the lifetime of the object:

from functools import cached_property
import statistics

class DataSet:
    def __init__(self, numbers):
        self._numbers = tuple(numbers)

    @cached_property
    def stdev(self):
        return statistics.stdev(self._numbers)

ds = DataSet([1, 2, 3, 4, 5])
ds.stdev              # computed once
ds.stdev              # read from the instance __dict__
ds.stdev = 0.0        # write succeeds and shadows the cache
del ds.stdev          # next access recomputes

Two non-obvious behaviors. First, in 3.12 the per-property lock that used to sit inside cached_property was removed; under contention, the getter can run more than once, and the last value wins. Add your own threading.Lock if that matters. Second, cached_property doesn’t work on classes that use __slots__ without a __dict__, and it interferes with PEP 412 key-sharing dicts, so each instance’s __dict__ takes a bit more memory than it would otherwise.

How does partial work for argument binding?

partial returns a callable with some arguments pre-bound. The most familiar use is parsing binary strings:

from functools import partial

basetwo = partial(int, base=2)
basetwo('10010')              # 18

def greet(greeting, name, punctuation='!'):
    return f'{greeting}, {name}{punctuation}'

say_hi = partial(greet, 'Hello')
say_hi('Ada')                          # 'Hello, Ada!'
say_hi('Ada', punctuation='.')         # 'Hello, Ada.'

Caller-provided positional and keyword arguments are appended to the bound ones and override them on conflict. The result exposes .func, .args, and .keywords for introspection.

partialmethod is the same idea for class methods. The bound self is inserted automatically when you look up the method on an instance, so you can build toggle-style APIs cleanly:

from functools import partialmethod

class Cell:
    def __init__(self):
        self._alive = False

    @property
    def alive(self):
        return self._alive

    def set_state(self, state):
        self._alive = bool(state)

    set_alive = partialmethod(set_state, True)
    set_dead = partialmethod(set_state, False)

c = Cell()
c.set_alive()
c.alive    # True
c.set_dead()
c.alive    # False

One thing to know: a plain partial does not pick up __name__ or __doc__ from the wrapped function, so introspection tools like help() and inspect.signature() see the partial, not the original. If that matters, either assign a docstring manually or use the Placeholder sentinel introduced in 3.14 to leave a slot open:

from functools import partial, Placeholder as _

# 'str.replace(old, new, count=-1)': bind new='', leave old and count open
remove = partial(str.replace, _, _, '')
remove('Hello, dear dear world!', ' dear')      # 'Hello, world!'

# Compose with another partial: ' dear' is now bound
remove_dear = partial(remove, _, ' dear')
remove_dear('Hello, dear dear world!')          # 'Hello, world!'

# Bind count=1 to keep just the first occurrence
remove_first_dear = partial(remove_dear, _, 1)
remove_first_dear('Hello, dear dear world!')    # 'Hello, dear world!'

Placeholder only works for positional arguments, and trailing placeholders are not allowed: partial(f, Placeholder, 'x') raises TypeError. If a placeholder is unfilled at call time, the partial also raises TypeError with a message that names the missing slot.

Why should you use @wraps in every decorator?

Whenever you write a decorator, put @wraps on the inner function. Without it, wrapper.__name__ becomes 'wrapper', help() shows the wrong docstring, and inspect.signature() lies about what the decorated function accepts:

from functools import wraps

def trace(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f'calling {func.__name__}{args!r}')
        result = func(*args, **kwargs)
        print(f'{func.__name__} returned {result!r}')
        return result
    return wrapper

@trace
def add(a, b):
    """Add two numbers."""
    return a + b

add(1, 2)
# output: calling add(1, 2)
# output: add returned 3

add.__name__      # 'add'
add.__doc__       # 'Add two numbers.'
add.__wrapped__   # <function add at 0x...>

@wraps is sugar over update_wrapper, which copies a fixed set of attributes from the wrapped function onto the wrapper. The defaults, taken from the cpython source, are:

  • WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__', '__annotate__', '__type_params__'): these are assigned, not merged.
  • WRAPPER_UPDATES = ('__dict__',): these are merged into the wrapper’s __dict__ with dict.update().

update_wrapper also sets wrapper.__wrapped__ last, so it doesn’t get clobbered by the __dict__ merge. __wrapped__ is what inspect.unwrap follows, and it’s how tools like pdb and help() reach the original function. Use update_wrapper directly when @wraps doesn’t fit, such as class-based decorators.

When should you reach for reduce?

reduce applies a two-argument function cumulatively left-to-right. The sum-of-a-list example is overused; the more interesting cases are factorials, flat builds, and string joins:

from functools import reduce
import operator

reduce(operator.add, [1, 2, 3, 4])         # 10
reduce(operator.mul, [1, 2, 3, 4], 1)       # 24  (factorial, with identity)

reduce(lambda a, b: a + [b], [], [])         # safe on empty input

Two edge cases worth memorizing. An empty iterable without an initial value raises TypeError("reduce() of empty iterable with no initial value"). Pass an initial value to make it total. As of 3.14 you can also pass initial as a keyword argument.

If you actually want every intermediate value, use itertools.accumulate. It returns the running totals rather than just the final one, and it accepts a func= argument when you want something other than addition.

A practical rule: if sum, math.prod, all, any, or a plain for loop expresses the intent more clearly, use those. Reach for reduce only when you really do want a left fold with an arbitrary binary function.

How does generic dispatch work with singledispatch?

singledispatch (PEP 443, 3.4) turns a function into a generic whose behavior depends on the type of its first argument. Overloads are registered with .register, and the decorator form reads the first-argument type hint automatically:

from functools import singledispatch

@singledispatch
def serialize(obj):
    raise TypeError(f'cannot serialize {type(obj).__name__}')

@serialize.register
def _(obj: int) -> str:
    return str(obj)

@serialize.register
def _(obj: list) -> str:
    return '[' + ', '.join(serialize(x) for x in obj) + ']'

@serialize.register
def _(obj: int | float) -> str:
    return str(obj)

serialize(42)               # '42'
serialize([1, 'x', 2.0])    # "[1, x, 2.0]"

Two important nuances. Dispatch uses the exact runtime type; subclasses do not automatically fall back to a base-class implementation. If you want ABC-style dispatch, register the ABC: singledispatch.register(MyABC, abc_impl). And for methods on a class, use singledispatchmethod (3.8): dispatch then happens on the second argument, with self first.

What do total_ordering and cmp_to_key provide?

total_ordering (3.2) fills in the rest of the rich-comparison methods once you define __eq__ and one of __lt__, __le__, __gt__, or __ge__. The decorator picks a root method in this order of preference: __lt__, __le__, __gt__, __ge__. The trade-off is slower execution and deeper stack traces, so for hot code, hand-write all six methods.

cmp_to_key (3.2) adapts a Python 2-style comparator (a, b) -> -1 | 0 | 1 to a key= callable that works with sorted(), min(), max(), heapq.nlargest, and itertools.groupby. The main use in modern code is locale-aware sorting:

from functools import cmp_to_key
import locale

words = ['éclair', 'apple', 'banana', 'äpfel']
sorted(words, key=cmp_to_key(locale.strcoll))

Common Mistakes

A few “don’t” rules that come up over and over:

  • Don’t cache functions whose return values are mutable, large, or contain sensitive data. The cache holds strong references until eviction.
  • Don’t put lru_cache on a method that holds self in the key, which leaks the instance. Use cached_property for per-instance values, or build a manual dict on the instance.
  • Don’t use reduce where sum, math.prod, all, any, or a for loop is clearer.
  • Don’t write a decorator without @wraps. Introspection breaks in subtle ways.
  • Don’t assume singledispatch walks the MRO. Register the base type (or ABC) explicitly.
  • Don’t pass Placeholder as a keyword argument; it only works positionally.
  • Don’t decorate a class with @total_ordering if you care about hot-path performance.

Frequently asked questions

What is functools used for in Python?

functools provides higher-order functions and operations on callables. It includes caching utilities (lru_cache, cache, cached_property), argument binding (partial, partialmethod), decorator helpers (wraps, update_wrapper), reduction (reduce), generic dispatch (singledispatch), and class comparison tools (total_ordering, cmp_to_key).

When should I use @cache vs @lru_cache?

Use @cache when the input space is small and bounded, like factorials or parsing constants, because it never evicts entries and has a faster code path:

from functools import cache

@cache
def parse_hex(s):
    return int(s, 16)

parse_hex('ff')  # 255
parse_hex('ff')  # 255 (cache hit)

Use @lru_cache(maxsize=N) when you need eviction to bound memory usage, such as caching network responses or large lookup tables.

Does functools work with classes?

Yes. cached_property adds per-instance memoization, total_ordering auto-generates comparison methods, and singledispatchmethod enables type-based dispatch on methods:

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)

partialmethod also works inside class bodies to create pre-bound method descriptors.

Is reduce still useful in modern Python?

For simple sums or products, prefer sum or math.prod for clarity. reduce shines when you need a left fold with an arbitrary binary function, like building nested structures, flattening lists, or implementing custom accumulators that the built-ins can’t express.

Conclusion

functools is a small module that pays for itself quickly: @lru_cache and @cache for memoization, cached_property for one-shot derived attributes, partial and partialmethod for binding arguments, @wraps for honest decorators, reduce for genuine left folds, singledispatch for type-based dispatch, and total_ordering plus cmp_to_key for the class-level odd jobs. Whether you’re doing functools functional programming or just need a quick cache, the module covers the common patterns without forcing you to reach for third-party libraries. Most of these are under twenty lines of code in the cpython source, and reading them there is a good way to internalize what they actually do.

If you want to keep going, the natural next stops are itertools (especially accumulate and the iterator combinators), operator (which provides the idiomatic callbacks for reduce and partial), and writing your own decorators from scratch now that you know what @wraps is doing for you.

See Also