functools Module Complexity¶
The functools module wraps callables: it memoizes them, binds arguments to them, copies their
metadata, folds them over an iterable and dispatches on an argument's type. The work is per call:
reduce walks its iterable, a dispatch miss walks the registry, and nothing else touches more than
the arguments it is handed. Memory is what an object was given to keep, plus the caches, which
grow with the distinct calls made through them.
f is the cost of one call of the user's function, whichever is wrapped, folded or compared.
a and m are the positional and keyword arguments supplied to one call, p and q the
positional and keyword bindings a partial stores. n is the entries a cache holds, the items
reduce folds or the elements a sort compares. k is the implementations registered on a generic function, t the distinct
argument types it has dispatched, and L the classes linearised on a dispatch miss: the argument
class's MRO plus the MROs of the registered ABCs that recognise the class without appearing in it,
and of those ABCs' subclasses that recognise it. P is the paths through that inheritance graph,
one per way each of the L classes can be reached, which is L unless a base is inherited more than
once. w is the
names in assigned when a wrapper is updated, and u the names in updated plus the entries
across the mappings they name. Hashing and comparing one argument, one attribute lookup and one
subclass check are treated as O(1).
Complexity Reference¶
lru_cache¶
| Operation | Time | Space | Notes |
|---|---|---|---|
functools.lru_cache(maxsize=128, typed=False) |
O(1) to build, O(w + u) to apply | O(min(n, maxsize)) entries | Applying it copies the function's metadata as update_wrapper does; the cache then holds at most maxsize entries, every entry with maxsize=None and none with maxsize=0, each holding its key and result; typed=True keys 3 and 3.0 separately |
functools.cache(user_function) |
O(w + u) | O(n) entries | lru_cache(maxsize=None): nothing is ever evicted |
| Calling a cached function, hit | O(a + m) | O(a + m) | The key is built from every argument and hashed before the lookup, which is then O(1) avg; keyword order is part of the key, and an unhashable argument raises TypeError before the function runs; with maxsize=0 no key is built and the function is simply called |
| Calling a cached function, miss | O(a + m + f) | O(a + m) | Stores the key and the result; with a positive maxsize the least recently used entry is evicted in O(1) |
cache_info() |
O(1) | O(1) | A fresh CacheInfo tuple of hits, misses, maxsize and currsize |
cache_clear() |
O(n) | O(1) | Drops every entry and zeroes the counters |
cache_parameters() |
O(1) | O(1) | A fresh dict of maxsize and typed |
__wrapped__ |
O(1) | O(1) | The undecorated function |
cached_property¶
| Operation | Time | Space | Notes |
|---|---|---|---|
functools.cached_property(func) |
O(1) | O(1) | Builds the descriptor |
| First access to the attribute | O(f) | O(1) | Stores the value in the instance __dict__, so an instance without one, such as a __slots__ class, raises TypeError |
| Later access | O(1) | O(1) | An ordinary instance attribute; del it to recompute |
partial¶
| Operation | Time | Space | Notes |
|---|---|---|---|
functools.partial(func, /, *args, **keywords) |
O(p + q) | O(p + q) | Copies the bindings; wrapping another partial merges the two sets rather than nesting, unless the inner one has a __dict__, which setting an attribute on it or reading vars() of it creates |
partial.func, partial.args, partial.keywords |
O(1) | O(1) | The stored callable, tuple and dict themselves; keywords is live, so mutating it changes later calls |
Calling a partial |
O(p + q + a + m + f) | O(p + q + a + m) | The stored and supplied arguments are joined into one call; stored keywords are copied and merged on every call |
functools.Placeholder |
O(1) | O(1) | Python 3.14+; a call's positional arguments fill the placeholders left to right within the same bound, and a call supplying fewer raises TypeError |
functools.partialmethod(func, /, *args, **keywords) |
O(p + q) | O(p + q) | Same storage and flattening; each access through an instance builds a new partial around the bound method, so obj.method costs O(p + q) before the call, or a fresh wrapper function in O(1) when func has no __get__ or its __get__ hands back func itself |
update_wrapper¶
| Operation | Time | Space | Notes |
|---|---|---|---|
functools.update_wrapper(wrapper, wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES) |
O(w + u) | O(w + u) | Each name in assigned is fetched and set, a missing one skipped but still looked up; each mapping updated names is fetched and every entry in it copied into the wrapper's |
functools.wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES) |
O(1) to build, O(w + u) when applied | O(w + u) | A partial of update_wrapper |
functools.WRAPPER_ASSIGNMENTS, functools.WRAPPER_UPDATES |
O(1) | O(1) | The default name tuples: a fixed handful of metadata names, and __dict__ |
reduce¶
| Operation | Time | Space | Notes |
|---|---|---|---|
functools.reduce(function, iterable[, initial]) |
O(n·f) | O(1) auxiliary, plus the accumulator | function runs n-1 times, n with initial; the accumulator is whatever function returns and is fed back in, so + on strings makes the fold O(n²) and a product of integers, whose width keeps growing, super-linear |
cmp_to_key and total_ordering¶
| Operation | Time | Space | Notes |
|---|---|---|---|
functools.cmp_to_key(func) |
O(1) | O(1) | Returns a key class; wrapping one element is O(1), and every comparison then calls func, so a sort pays O(n log n) calls of f rather than n key computations |
functools.total_ordering(cls) |
O(1) | O(1) | Fills in the missing comparison methods from the one defined and __eq__; a derived comparison calls up to two of them |
singledispatch¶
| Operation | Time | Space | Notes |
|---|---|---|---|
functools.singledispatch(func) |
O(w + u) | O(w + u) | Registers func for object and copies its metadata as update_wrapper does |
singledispatch.register(cls, func=None) |
O(t), plus one entry per union member | O(1) per entry | One registry entry, or one per member of a union type (Python 3.11+), and the t cached entries are cleared; the annotation form evaluates the function's annotations first |
singledispatch.dispatch(cls) |
O(1) avg hit, O(k + s + P·L²) miss | O(t), plus O(s + L²) during a miss | An exactly registered class is found without a search; any other miss checks every registration against the class, checks the s direct subclasses of the registered ABCs that recognise it, and linearises its MRO with those ABCs in Python, revisiting a base once per path to it, then caches the result weakly per class; once an ABC is registered on its own, not as a union member, an ABCMeta.register() call anywhere in the process that adds a virtual subclass invalidates the cache, which is cleared on the next dispatch |
| Calling a generic function | O(a + m + f) after the first call per type | O(a + m) | Forwards the arguments to the implementation for the class of the first positional one; a call without one raises TypeError |
singledispatch.registry |
O(1) | O(1) | A read-only live view of the registry, not a copy |
functools.singledispatchmethod(func) |
O(w + u) | O(w + u) | Wraps a singledispatch of func |
singledispatchmethod.register(cls, method=None) |
O(t), plus one entry per union member | O(1) per entry | The wrapped generic function's register() |
Accessing obj.method |
O(1) | O(1) | Python 3.14+; before 3.14 each access builds a function and copies the implementation's metadata, O(w + u) |
Calling obj.method(arg, ...) |
O(a + m + f) after the first call per type | O(a + m) | After the access priced above: dispatches on the argument after self, binds the implementation and forwards the arguments |
Memoization¶
Memoizing Recursion¶
Each distinct argument is computed once. The recursion still costs a call per level, so
fibonacci(n) makes O(n) calls, where the same function without the cache makes O(2ⁿ).
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2) # O(1) per hit
assert fibonacci(30) == 832040 # O(n) calls, one miss per distinct n
info = fibonacci.cache_info() # O(1)
assert info.misses == 31 # n from 0 to 30
assert info.hits == 28
fibonacci.cache_clear() # O(n)
assert fibonacci.cache_info().currsize == 0
What a Call Key Costs¶
A hit is not free. The key is built from every positional and keyword argument and hashed before the dictionary is consulted, so a call with many arguments pays for all of them whether or not the result is cached.
from functools import lru_cache
@lru_cache(maxsize=128)
def combine(*args, **kwargs):
return len(args) + len(kwargs)
combine(1, 2, x=3) # miss: O(a + m + f)
combine(1, 2, x=3) # hit: O(a + m) to build and hash the key
assert combine.cache_info().hits == 1
# Keyword order is part of the key, so these are two entries
combine(a=1, b=2)
combine(b=2, a=1)
assert combine.cache_info().currsize == 3
# An unhashable argument fails before the function runs
try:
combine([1, 2])
except TypeError as error:
assert 'unhashable' in str(error)
else:
raise AssertionError('a list was accepted as a cache key')
@lru_cache(typed=True)
def scale(x, factor):
return x * factor
assert scale(4, 1) == scale(4.0, 1) == 4
assert scale.cache_info().currsize == 2 # 4 and 4.0 are separate entries under typed=True
Bounding the Cache¶
maxsize picks one of three modes. A positive value evicts the least recently used entry once
the cache is full, None keeps every entry until cache_clear() or the function itself goes away,
and 0 keeps none.
from functools import cache, lru_cache
@lru_cache(maxsize=2)
def square(n):
return n * n
square(1)
square(2)
square(1) # 1 is now the most recently used
square(3) # evicts 2, the least recently used - O(1)
assert square.cache_info().currsize == 2
square(2) # a miss again
assert square.cache_info().misses == 4
@cache # lru_cache(maxsize=None): O(n), never evicts
def cube(n):
return n ** 3
for n in range(1000):
cube(n)
assert cube.cache_info() == (0, 1000, None, 1000)
assert cube.cache_parameters() == {'maxsize': None, 'typed': False} # O(1)
assert cube.__wrapped__(3) == 27 # O(1): the undecorated function
@lru_cache(maxsize=0) # stores nothing: every call is a miss
def identity(n):
return n
identity(1)
identity(1)
assert identity.cache_info() == (0, 2, 0, 0)
Caching Methods¶
On a method, self is part of every key, so the cache keeps each instance alive until its entries
go. A class with many or large instances pays that in memory for as long as the cache lasts.
import gc
import weakref
from functools import lru_cache
class Report:
@lru_cache(maxsize=None)
def total(self):
return 42
report = Report()
ref = weakref.ref(report)
assert report.total() == 42 # the key is (self,), so the entry holds the instance
del report
gc.collect()
assert ref() is not None # still alive: the cache holds it
Report.total.cache_clear() # O(n)
gc.collect()
assert ref() is None
cached_property¶
The value is computed on the first access and stored as an ordinary attribute on the instance, so
every later access is a plain attribute lookup with no key to build. The instance needs a
__dict__.
from functools import cached_property
class Dataset:
def __init__(self, values):
self.values = values
self.computations = 0
@cached_property
def total(self):
self.computations += 1
return sum(self.values) # O(f), once per instance
data = Dataset(range(1000))
assert data.total == 499500 # first access: computes and stores in __dict__
assert data.total == 499500 # O(1): an ordinary attribute now
assert data.computations == 1
assert 'total' in vars(data)
del data.total # dropping the attribute recomputes on the next access
assert data.total == 499500
assert data.computations == 2
class Slotted:
__slots__ = ('values',)
@cached_property
def total(self):
return 0
try:
Slotted().total
except TypeError as error:
assert '__dict__' in str(error)
else:
raise AssertionError('a cached_property was stored without a __dict__')
Partial Objects¶
Binding Arguments¶
Building a partial copies its bindings once; every call then joins them with the call's own
arguments before the function runs. Wrapping a partial in another merges the bindings instead
of stacking a second call.
from functools import partial
def power(base, exponent, *, modulus=None):
result = base ** exponent
return result if modulus is None else result % modulus
square = partial(power, exponent=2) # O(p + q): stores one keyword binding
assert square.func is power # O(1)
assert square.args == ()
assert square.keywords == {'exponent': 2}
assert square(7) == 49 # O(p + q + a + m) to assemble the call, then f
# Wrapping a partial merges the bindings instead of nesting a second call
square_mod_10 = partial(square, modulus=10)
assert square_mod_10.func is power
assert square_mod_10.keywords == {'exponent': 2, 'modulus': 10}
assert square_mod_10(7) == 9
# keywords is the live dict: a change reaches later calls
square.keywords['exponent'] = 3
assert square(2) == 8
Placeholders¶
Placeholder (Python 3.14+) reserves a positional slot. It is stored like any other binding, and a
call's positional arguments fill the reserved slots left to right before any are appended.
import sys
from functools import partial
if sys.version_info >= (3, 14):
from functools import Placeholder
def divide(numerator, denominator):
return numerator / denominator
halve = partial(divide, Placeholder, 2) # O(p): the placeholder is one stored binding
assert halve.args == (Placeholder, 2)
assert halve(10) == 5.0 # the call's first argument fills the slot
try:
halve()
except TypeError as error:
assert 'missing positional arguments' in str(error)
else:
raise AssertionError('a placeholder was left unfilled')
partialmethod¶
A partialmethod stores its bindings once, at class creation. Reading it through an instance
builds a fresh partial around the bound method every time, so bind once outside a loop rather
than reading the attribute on every iteration. Only a func whose __get__ yields nothing new,
such as a callable instance without one, escapes this: it gets a wrapper function that defers the
bindings to the call.
from functools import partial, partialmethod
class Cell:
def __init__(self):
self.state = 'off'
def set_state(self, state):
self.state = state
turn_on = partialmethod(set_state, 'on') # O(p + q), once
turn_off = partialmethod(set_state, 'off')
cell = Cell()
bound = cell.turn_on # O(p + q): a new partial on every access
assert isinstance(bound, partial)
assert bound is not cell.turn_on
bound()
assert cell.state == 'on'
cell.turn_off()
assert cell.state == 'off'
Wrapping Functions¶
update_wrapper() copies a fixed handful of names and then the wrapped function's whole
__dict__, so a decorator costs whatever the function it wraps carries. wraps() is the same
call, deferred until the decorator applies it.
from functools import WRAPPER_ASSIGNMENTS, WRAPPER_UPDATES, update_wrapper, wraps
def logged(func):
@wraps(func) # O(w + u) when applied
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def add(a, b):
"""Add two numbers."""
return a + b
add.registered = True # one entry in add.__dict__, copied by the wrap
logged_add = logged(add)
assert logged_add.__name__ == 'add'
assert logged_add.__doc__ == 'Add two numbers.'
assert logged_add.registered is True
assert logged_add.__wrapped__ is add # O(1): a reference, set last
assert '__name__' in WRAPPER_ASSIGNMENTS
assert WRAPPER_UPDATES == ('__dict__',)
def target():
pass
update_wrapper(target, add, assigned=('__doc__',), updated=()) # w = 1, u = 0
assert target.__doc__ == 'Add two numbers.'
assert target.__name__ == 'target'
assert not hasattr(target, 'registered')
Folding With reduce¶
reduce() walks the iterable once and calls the function once per step. Its cost is the
function's, and the accumulator is whatever the function returns: a function that copies the
accumulated value to extend it, as + on a string or list does, pays for the whole of it every
step.
import operator
from functools import reduce
data = [1, 2, 3, 4, 5]
assert reduce(operator.add, data) == 15 # O(n·f): n-1 calls
assert reduce(operator.add, [], 0) == 0 # initial: the empty fold's result
assert reduce(lambda acc, item: f'({acc}-{item})', ['1', '2', '3']) == '((1-2)-3)'
try:
reduce(operator.add, [])
except TypeError as error:
assert 'empty' in str(error)
else:
raise AssertionError('an empty fold without initial returned')
# Concatenation copies everything accumulated so far on each step: O(n²)
joined = reduce(operator.add, ['x'] * 1000)
assert len(joined) == 1000
# A product widens too: each multiplication is dearer than the last
product = reduce(operator.mul, range(1, 1001))
assert product.bit_length() > 8000
# max keeps the accumulator to one of the inputs, so the fold stays O(n)
assert reduce(max, data) == 5
Comparison Helpers¶
cmp_to_key¶
The wrapper defers the work to the comparisons. sorted(key=cmp_to_key(f)) wraps each element in
O(1) and then calls f on every comparison, where a key= function runs once per element and the
comparisons that follow are on the keys alone.
from functools import cmp_to_key
comparisons = []
def compare(left, right):
comparisons.append((left, right))
return (left > right) - (left < right)
data = [5, 3, 8, 1, 9, 2, 7, 4, 6, 0]
assert sorted(data, key=cmp_to_key(compare)) == list(range(10)) # O(n log n) calls of compare
assert len(comparisons) > len(data) # more calls than elements
key_calls = []
def key(value):
key_calls.append(value)
return value
assert sorted(data, key=key) == list(range(10)) # O(n) key calls, then O(n log n) comparisons
assert len(key_calls) == len(data)
total_ordering¶
The decorator fills in the missing comparison methods once, at class creation. Each derived
comparison is expressed through the method you wrote and __eq__, so it can cost two calls
where a hand-written one costs one.
from functools import total_ordering
calls = []
@total_ordering
class Version:
def __init__(self, number):
self.number = number
def __eq__(self, other):
calls.append('eq')
return self.number == other.number
def __lt__(self, other):
calls.append('lt')
return self.number < other.number
assert Version(1) < Version(2) # the method you wrote: one call
assert calls == ['lt']
calls.clear()
assert Version(2) > Version(1) # derived: not __lt__, then __ne__ - two calls
assert calls == ['lt', 'eq']
calls.clear()
assert Version(1) >= Version(1) # derived as not __lt__: one call
assert calls == ['lt']
Single Dispatch¶
The Dispatch Cache¶
The first call for an argument type is a miss: every registration is checked against the class
and its MRO is linearised with the registered ABCs that recognise it. The result is cached per
class, so later calls are one dictionary lookup, until the next register() clears the cache.
from collections.abc import Sized
from functools import singledispatch
@singledispatch
def describe(value):
return 'object'
@describe.register(int) # O(t): one entry, and the cache is cleared
def _(value):
return 'int'
@describe.register # the annotation form
def _(value: list):
return 'list'
assert describe(3) == 'int' # int is registered: found without a search
assert describe(True) == 'int' # miss for bool: O(k + s + P·L²), its MRO reaches int
assert describe(True) == 'int' # hit: O(1) avg
assert describe([1]) == 'list'
assert describe('text') == 'object'
assert describe.dispatch(bool) is describe.dispatch(int)
registry = describe.registry # O(1): a live read-only view, not a copy
assert set(registry) == {object, int, list} # O(k) to materialise
@describe.register(Sized) # an ABC: from now on a new virtual subclass
def _(value): # registered on any ABC invalidates this cache
return 'sized'
assert describe('text') == 'sized' # str is not a subclass, but Sized recognises it
assert describe(3) == 'int' # an exact registration needs no linearisation
singledispatchmethod¶
The method form dispatches on the first argument after self, then binds the implementation it
found. Registering on it registers on the wrapped generic function, with the same cache.
from functools import singledispatchmethod
class Formatter:
@singledispatchmethod
def render(self, value):
return repr(value)
@render.register
def _(self, value: int):
return f'{value:+d}'
formatter = Formatter()
assert formatter.render(3) == '+3' # dispatch on value, then f
assert formatter.render('x') == "'x'"
assert formatter.render(True) == '+1' # bool dispatches to the int implementation
Common Patterns¶
Binding a Callback Once¶
from functools import partial
def scale(value, factor):
return value * factor
doubler = partial(scale, factor=2) # O(p + q), once
assert list(map(doubler, range(5))) == [0, 2, 4, 6, 8] # O(p + q + a + m + f) per element
Performance Best Practices¶
✅ Do:
- Register every implementation before the first call: each
register()clears the dispatch cache, and the first call per type then pays the miss again - Give
lru_cacheamaxsizethat fits the working set;maxsize=Noneholds every distinct call until something clears it - Reach for
cached_propertywhen the value is per instance: one attribute store, no key to build and hash - Prefer
key=overcmp_to_key()when a key function exists: n key calls instead of O(n log n) comparison calls
❌ Avoid:
lru_cacheon a method of a class with many or large instances:selfis in every key, so the cache keeps them alivereduce()with a function that rebuilds a growing accumulator, such as+on strings or lists: every step copies it, and''.join()orextend()does the same work once- Reading a
partialmethodof an ordinary method inside a loop: every access builds a newpartial - Registering an ABC on a generic function that is called often: every new
ABCMeta.register()anywhere in the process then invalidates its cache
Version Notes¶
- Python 3.11+:
register()accepts a union type and adds one registry entry per member - Python 3.12+:
cached_propertyno longer takes a lock around the first computation, so instances computed on different threads no longer serialise on one per-property lock - Python 3.13+: A
partialstored as a class attribute stops being a plain attribute: 3.13 warns withFutureWarningwhen it is read through an instance, and 3.14 binds it as a method, passing the instance first - Python 3.14+:
Placeholderis added;reduce()acceptsinitialas a keyword; reading asingledispatchmethodthrough an instance is O(1) instead of a metadata copy - All Python 3:
register()clears the whole dispatch cache, not one type's entry
Related Modules¶
- operator -
itemgetterandattrgetterbuild key functions that run once per element - itertools -
accumulate()yields every intermediate result of the foldreduce()returns only the last of - abc -
ABCMeta.register()andget_cache_token(), which decide when a dispatch cache is cleared - weakref -
WeakKeyDictionary, the structure a dispatch cache is, so a collected class costs it nothing