Python functools Tutorial: partial, reduce, Caching and Decorators

Learn when and how to use Python's main function tools through runnable examples with exact outputs, step-by-step traces, common mistakes and practice checks.

KnowledgeGate Team

Exam prep & CS education

Updated 3 Nov 20266 min read
Python functools Tutorial: partial, reduce, Caching and Decorators

Functions are easy to define, but repeated argument fixing, recalculation, decorator wrappers and type checks can make code noisy. Python's functools module gives you focused tools for those jobs. The choice becomes straightforward once you trace which arguments are bound, which calls are cached, and which metadata or dispatch rule changes.

What functools does and when to use it

The official Python functools documentation defines the module as higher-order functions and operations on callable objects. In plain language, higher-order code accepts, returns or adapts functions:

python
def double(x):
    return 2 * x

def apply(func, value):
    return func(value)

print(apply(double, 5))  # 10

Match the job to the tool: bind arguments with partial, fold values with reduce, reuse results with lru_cache or cache, preserve decorator metadata with wraps, dispatch on the first argument's type with singledispatch, and adapt an old comparator with cmp_to_key.

Prefer sum, min, max, comprehensions, ordinary functions and a direct key= when they already express the task clearly. The module also includes helpers such as cached_property and total_ordering, but each solves a different job. For the broader choice among file, data, date and command-line modules, use Python Standard Library Tour. functools stays focused on callable adapters and cached call results. The Coding & Skills courses provide a wider Python learning path.

Freeze arguments cleanly with partial

python
from functools import partial

def clamp(value, low, high):
    return max(low, min(value, high))

as_percent = partial(clamp, low=0, high=100)
raw_scores = [-12, 67, 108]
print([as_percent(score) for score in raw_scores])
print(as_percent(108, high=90))
Code
[0, 67, 100]
90

partial creates a callable without running clamp. Here, -12 rises to 0, 67 stays 67, and 108 falls to 100. A call-time keyword overrides a stored keyword, so the last call uses high=90 and returns 90. A partial object exposes .func, .args and .keywords for inspection.

lambda score: clamp(score, 0, 100) also works. Use partial when the intention is specifically to bind an existing function's arguments. Do not write partial(clamp(-12, 0, 100)): it runs clamp immediately, gets integer 0, then fails because partial expected a callable.

Partial binds clamp low=0 and high=100, producing 0, 67, 100, then clamp high=90 returns 90.

Fold many values into one with reduce

python
from functools import reduce
from operator import mul

values = [2, 3, 4]
product = reduce(mul, values, 1)
print(product)
print(reduce(mul, [], 1))
Code
24
1

reduce works from left to right. Starting with initializer 1: 1 * 2 = 2, 2 * 3 = 6, then 6 * 4 = 24. With an empty list, no item is consumed, so it returns the initializer 1. Without an initializer, reduce(mul, []) raises TypeError.

Use an initializer only when it is a real identity or business starting value. For addition, reduce(lambda total, score: total + score, [12, 18, 25, 5], 0) returns 60, but sum([12, 18, 25, 5]) says the same thing more clearly. The Python Lambda and Higher-Order Functions tutorial develops filter-map-reduce pipelines, closures and late-binding traps; functools.reduce specifically adds the initializer and empty-input decisions traced above.

Reuse deterministic results with lru_cache and cache

python
from functools import lru_cache

@lru_cache(maxsize=4)
def square(number):
    print(f"compute {number}")
    return number * number

results = [square(number) for number in [2, 3, 2, 4, 5, 3]]
print(results)
print(square.cache_info())
Code
compute 2
compute 3
compute 4
compute 5
[4, 9, 4, 16, 25, 9]
CacheInfo(hits=2, misses=4, maxsize=4, currsize=4)

The first calls for 2, 3, 4 and 5 are four misses. The repeated 2 and 3 are two hits, so the function body does not print again. Arguments must be hashable. cache_info() reports usage, while cache_clear() invalidates stored results.

An LRU cache evicts the least recently used entry when full. @cache is unbounded, so use it only when growth is acceptable. Cache only results that are safe to reuse for identical arguments, not answers based on current time, random effects, changing external state or fresh database values. Memoisation can reduce repeated work, but it does not improve every algorithm.

Timeline of the cached square calls 2, 3, 2, 4, 5, 3 showing four misses sent to compute and two hits, ending at hits = 2, misses = 4.

Preserve decorator metadata with wraps

python
from functools import wraps

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

@trace
def area(width, height):
    """Return rectangle area."""
    return width * height

print(area(6, 4))
print(area.__name__)
print(area.__doc__)
Code
calling area(6, 4)
24
area
Return rectangle area.

The decorated name area refers to wrapper. It logs the original name, forwards (6, 4), and returns 6 * 4 = 24. wraps is a convenient form of update_wrapper: it preserves selected metadata and exposes __wrapped__. Without it, basic introspection reports wrapper and loses the original docstring. It does not validate arguments, catch exceptions or speed up the function. The Decorators in Python tutorial owns wrapper construction, parameterised decorators and stacking order; wraps addresses the narrower problem of preserved metadata inside those wrappers.

Dispatch by type and adapt legacy comparators

python
from functools import singledispatch

@singledispatch
def describe(value):
    return f"other:{value}"

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

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

print(describe(7))
print(describe([3, 5, 8]))
print(describe("hi"))
Code
int:14
list:3
other:hi

singledispatch selects an implementation from the first argument's type, and registration can infer that type from an annotation. Because bool subclasses int, describe(True) returns int:2 unless you register bool separately.

python
from functools import cmp_to_key

def by_last_digit(a, b):
    return (a % 10 > b % 10) - (a % 10 < b % 10)

numbers = [42, 17, 23, 35]
print(sorted(numbers, key=cmp_to_key(by_last_digit)))
Code
[42, 23, 35, 17]

cmp_to_key converts the three-way comparator into a key wrapper. The output follows last digits 2, 3, 5, 7. A comparator returns a negative number, zero or a positive number, not merely a Boolean. For new code, prefer key=lambda number: number % 10; cmp_to_key is mainly for adapting an existing comparator. Broader sorting choices still depend on stability and complexity.

Common functools traps and test patterns

  • Immediate execution passed to partial: pass the function, not its result.

  • Empty reduce call: provide a meaningful initializer.

  • List passed to lru_cache: accept a tuple when that matches the data, because a list is unhashable.

  • Time-dependent or side-effecting call cached: do not cache it, or define explicit invalidation.

  • Decorator loses name and docstring: place @wraps(func) directly above the wrapper.

Also remember that singledispatch checks only the first argument, and cmp_to_key adapts a comparator rather than replacing a clear key function.

Predict-output tests often check left association and cache accounting. reduce(lambda a, b: a - b, [20, 5, 3]) computes (20 - 5) - 3 = 12. The earlier partial returns [0, 67, 100], the cache trace has 2 hits and 4 misses, and describe(True) returns int:2. Apply the same method to any standard-library trace: predict the state change first, then run the code.

Exercises, the short version and next step

Try these before reading the checks:

  1. Create as_exam_score = partial(clamp, low=0, high=50) and clean [-3, 42, 65].

  2. Calculate reduce(mul, [5, 2, 3], 1), then run the same operation on [].

  3. Call an @lru_cache(maxsize=2) square function with [1, 2, 1, 3, 2].

Checks: the results are [0, 42, 50]; 30 and 1; then values [1, 4, 1, 9, 4] with CacheInfo(hits=1, misses=4, maxsize=2, currsize=2). The final 2 is a miss because 3 evicted it after the earlier hit made 1 most recent.

The selector is short: bind with partial; fold with reduce when no clearer built-in fits; memoise deterministic calls with lru_cache or cache; preserve metadata with wraps; register first-argument type behaviour with singledispatch; adapt old comparators with cmp_to_key.

Next, build the Python language and standard-library foundation. Before moving on, change the cache exercise to [1, 2, 3, 1] and predict why all four calls miss.