functools is the standard library module for functional-programming-style tools that treat functions as first-class values to be transformed, cached, and specialized. This lesson covers the four most practically useful, beyond functools.wraps (already covered in Decorators).
1reduce(): The General Case Behind sum() and Friends
reduce(function, iterable, initial) repeatedly applies a two-argument function to an accumulator and each successive item of the iterable, folding the whole sequence down to one final value ā reduce(lambda acc, x: acc + x, [1,2,3,4,5], 0) computes ((((0+1)+2)+3)+4)+5, step by step, ending at 15. This is precisely the general mechanism that Python's built-in sum() implements as a specific, optimized case; reduce generalizes the same 'fold' pattern to any binary operation, not just addition.
reduce is genuinely useful, but it's worth knowing it sits at the more general, less-readable end of Python's functional toolkit ā for the extremely common cases of summing, finding a max/min, or concatenating, the dedicated built-ins (sum(), max(), min(), "".join()) are more readable and usually just as fast, since reduce with a lambda obscures the specific operation behind generic accumulator syntax. reduce earns its place for genuinely custom fold operations ā accumulating into a more complex structure, or applying a domain-specific combining function that has no dedicated built-in.
The initial argument (the third one, 0 and 1 in the examples) matters for two reasons: it defines the starting value for the accumulator, and it makes reduce behave sensibly on an empty iterable (returning the initial value directly) rather than raising TypeError: reduce() of empty iterable with no initial value, which is what happens if you omit it and the iterable turns out to be empty.
from functools import reduce
numbers = [1, 2, 3, 4, 5]
total = reduce(lambda acc, x: acc + x, numbers, 0) # 15
product = reduce(lambda acc, x: acc * x, numbers, 1) # 120 -- factorial-style accumulation15 ā the general mechanism behind sum()
2partial(): Pre-Filling Arguments to Adapt a Function's Shape
partial(power, exponent=2) returns a new callable ā not a result, a *function-like object* ā that, when eventually called, invokes power with exponent already fixed to 2, needing only the remaining argument (base) to be supplied at call time. This is directly useful anywhere an API expects a callable with a specific, fixed signature (a one-argument function, say, for a callback or a map() call), but the function you actually want to use takes more arguments than that ā partial lets you adapt the shape without writing a small wrapper def by hand.
This connects directly back to the Strategy Pattern from the Design Patterns lesson: partial is frequently how you construct a specific 'strategy' instance from a more general, parameterized function ā square and cube are both derived from the same underlying power logic, specialized for a specific use, in one line each, without duplicating any of power's implementation.
partial also supports pre-filling *positional* arguments (partial(power, 2) fixes base to 2 instead), and the resulting partial object exposes .func, .args, and .keywords attributes if you ever need to introspect exactly what was pre-filled ā useful for debugging or for tooling that needs to inspect a partial's configuration rather than just call it.
from functools import partial
def power(base: float, exponent: float) -> float:
return base ** exponent
square = partial(power, exponent=2) # 'exponent' is now pre-filled
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(5)) # 125square(5) ā 25 ā exponent already fixed
3lru_cache and singledispatch: Memoization and Type-Based Dispatch
@lru_cache(maxsize=None) wraps a function so that every unique combination of arguments it's called with has its return value stored (memoized) after the first computation ā every subsequent call with that exact same set of arguments returns the cached result instantly, skipping recomputation entirely. This is precisely why fibonacci(35), naively recursive and otherwise exponentially slow (recomputing the same sub-values an enormous number of times), becomes fast: each unique n is computed exactly once, ever, across the entire call tree, and every repeated call just looks it up.
maxsize=None means the cache grows unbounded, which is fine for a pure function like Fibonacci with a naturally small, bounded domain of realistic inputs, but is a genuine memory consideration for a function called with a huge or unbounded variety of argument values ā maxsize=128 (the default) evicts the least-recently-used entries once the cache is full, bounding memory at the cost of occasionally recomputing a value that fell out of the cache. lru_cache requires every argument to be hashable, since it uses them as a dict key internally ā a function taking a list argument cannot be cached directly without converting to a hashable type like a tuple first.
@singledispatch solves a different problem: writing one function whose *behavior* varies by the runtime type of its first argument, without a manual if isinstance(value, int): ... elif isinstance(value, list): ... chain. Each @describe.register-decorated function handles one specific type, and calling describe(x) automatically dispatches to whichever registered implementation matches type(x) ā a clean, extensible alternative (new types register their own handler without modifying describe itself, an application of the Open/Closed Principle from the SOLID lesson) to a growing isinstance chain.
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(35) # fast -- each unique n is computed exactly once, everEach unique n computed exactly once ā linear, not exponential
4Step-by-Step Breakdown
reduce(), partial(), and lru_cache() are three of the most reused tools in professional Python, each solving a different 'do something to/with a function' problem. Let's meet them.
reduce() folds an iterable down to a single value by repeatedly applying a function -- it's the general case behind sum(), and behind writing your own accumulator loops.
partial() pre-fills some arguments of a function, returning a new callable that only needs the rest -- useful for adapting a function's signature to fit an API.
Checkpoint: What does square = partial(power, exponent=2) actually create?
- āA new callable that calls power() with exponent already fixed to 2, needing only base
- āThe immediate result of calling power(exponent=2), i.e. a number
lru_cache() memoizes a function's results -- repeated calls with the same arguments skip recomputation entirely, trading memory for speed.
Checkpoint: Why does @lru_cache make repeated calls to fibonacci(35) fast, when the naive recursive version is extremely slow?
- āIt memoizes results ā each unique argument value is computed once and reused on every subsequent call
- āIt automatically parallelizes the recursive calls across CPU cores
singledispatch lets a function behave differently based on the TYPE of its first argument -- a lightweight alternative to a chain of isinstance checks.
functools rounds out the functional-programming toolkit ā datetime shifts focus to the specific domain of handling dates and times correctly.
Fold Real Data with reduce. Finish product_of(): reduce() folds an iterable down to a single value.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Reach for sum()/max()/min() over reduce() for their specific common cases
reduce() is more general but less immediately readable than a dedicated built-in ā save reduce() for genuinely custom fold operations with no direct built-in equivalent.
Set an explicit maxsize on lru_cache unless the function's input domain is genuinely small and bounded
maxsize=None grows the cache without limit, which is a real memory risk for a function called with a large or unbounded variety of arguments over a long-running process.
Frequent Bugs
Applying @lru_cache to a method that takes a mutable argument (like a list or dict), causing a TypeError: unhashable type, since lru_cache requires all arguments to be hashable.
Convert mutable arguments to an immutable, hashable equivalent (tuple instead of list, a frozenset instead of a set) before calling the cached function, or restructure the function to accept hashable arguments directly.
Real-World Examples
Caching Expensive Configuration Parsing With lru_cache
A CLI tool parses and validates a configuration file on every command invocation within the same process, and the same file path is often requested repeatedly across different parts of the tool during one run.
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=32)
def load_config(config_path: str) -> dict:
print(f"Parsing {config_path}...") # only prints ONCE per unique path
return parse_and_validate(Path(config_path).read_text())