Functional Programming with Python `functools`

Learn how to use Python's functools module for higher-order functions, caching, partial application, and function composition.

7 min read

The functools module in the Python standard library provides tools for working with higher-order functions: functions that take other functions as arguments or return them as results. Use functools to cache expensive computations, pre-fill function arguments, compose functions, and preserve function metadata when writing decorators.

pythonpython
from functools import cache
 
@cache
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
 
print(fibonacci(100))   # 354224848179261915075

@cache automatically stores the result of every call. Without it, fibonacci(100) would take an impossibly long time because of exponential repeated work. With caching, each fibonacci(n) is computed exactly once.

Caching with cache and lru_cache

functools.cache (Python 3.9+) memoizes all calls without an upper bound. functools.lru_cache limits the cache size and evicts the least recently used entries once the cache is full.

pythonpython
from functools import lru_cache
 
call_count = 0
 
@lru_cache(maxsize=4)
def slow_square(n):
    global call_count
    call_count += 1
    return n * n

Call slow_square with a mix of repeated and new values, then check how many actually triggered a computation versus how many hit the cache:

pythonpython
for x in [1, 2, 3, 4, 1, 2, 5, 6, 1]:
    slow_square(x)
 
print(f"Actual computations: {call_count}")
print(slow_square.cache_info())

Even though slow_square was called nine times, only six of them were unique inputs, and the cache_info() line confirms exactly that split between hits and misses:

texttext
Actual computations: 6
CacheInfo(hits=3, misses=6, maxsize=4, currsize=4)

Only 6 unique values triggered actual computation. The 3 repeated values hit the cache. cache_info() shows the hit/miss statistics.

Key differences:

Featurecachelru_cache(maxsize=N)
Cache sizeUnlimitedLimited to N entries
EvictionNone (grows forever)Least recently used
MemoryCan grow largeBounded
Use caseFew unique inputsMany unique inputs

Use cache when the number of unique inputs is small or bounded. Use lru_cache when many different inputs are possible and memory is a concern.

Partial application with partial

functools.partial fixes some arguments of a function, creating a new function that requires fewer arguments.

pythonpython
from functools import partial
 
def power(base, exponent):
    return base ** exponent
 
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
 
print(square(5))   # 25
print(cube(5))     # 125

partial(power, exponent=2) creates a new function where exponent is always 2. The caller only provides the remaining argument. This is useful for adapting function signatures to match callback expectations or simplifying repeated calls with the same arguments.

pythonpython
import json
from functools import partial
 
pretty_json = partial(json.dumps, indent=2, sort_keys=True)
print(pretty_json({"c": 3, "a": 1, "b": 2}))

Even though the keys were passed in c, a, b order, sort_keys=True reorders them alphabetically in the formatted output, and indent=2 adds the readable line breaks and spacing:

jsonjson
{
  "a": 1,
  "b": 2,
  "c": 3
}

pretty_json is a simplified version of json.dumps with preset formatting options.

reduce: combining an iterable

functools.reduce applies a function cumulatively to items of an iterable, reducing it to a single value.

pythonpython
from functools import reduce
 
numbers = [1, 2, 3, 4, 5]
 
total = reduce(lambda acc, n: acc + n, numbers)
product = reduce(lambda acc, n: acc * n, numbers)
maximum = reduce(lambda acc, n: acc if acc > n else n, numbers)
 
print(total, product, maximum)

Each reduce call folds the list into a single number using a different combining function, producing the sum, product, and maximum in that order:

texttext
15 120 5

reduce works step by step: it calls the function with the accumulated result so far and the next item. For summing and finding maximums, built-in sum() and max() are clearer. Use reduce for custom accumulation logic that has no dedicated built-in.

pythonpython
from functools import reduce
 
words = ["Hello", "functional", "Python"]
sentence = reduce(lambda acc, w: f"{acc} {w}", words)
print(sentence)   # Hello functional Python

Preserving metadata with wraps

functools.wraps preserves a decorated function's name, docstring, and metadata. Always use it when writing decorators, since without it the wrapper hides the original function's identity from tools that introspect it.

pythonpython
from functools import wraps
 
def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Apply the decorator to a small function with a docstring, then inspect its name and docstring after decoration to confirm they were preserved:

pythonpython
@log_call
def greet(name):
    """Return a greeting."""
    return f"Hello, {name}!"
 
print(greet("Maya"))
print(greet.__name__)
print(greet.__doc__)

The wrapper's print statement fires first, followed by the greeting itself, then the preserved name and docstring of the original greet function:

texttext
Calling greet
Hello, Maya!
greet
Return a greeting.

Without @wraps, greet.name would be "wrapper" and greet.doc would be None. wraps copies the original function's attributes to the wrapper.

singledispatch: type-based functions

functools.singledispatch creates a function that behaves differently based on the type of its first argument. It is function overloading by type.

pythonpython
from functools import singledispatch
 
@singledispatch
def format_value(value):
    return str(value)
 
@format_value.register
def _(value: int):
    return f"INT:{value}"
 
@format_value.register
def _(value: list):
    return "[" + ", ".join(format_value(v) for v in value) + "]"

With the base function and two registered overloads defined, calling format_value with different argument types dispatches to the matching implementation automatically:

pythonpython
print(format_value(42))
print(format_value([1, 2, 3]))
print(format_value("hello"))

The int and list arguments hit their registered overloads, while the plain string falls back to the base implementation, which just calls str() on it:

texttext
INT:42
[INT:1, INT:2, INT:3]
hello

The base implementation handles unknown types. Registered implementations handle int and list. The function name _ is conventional for singledispatch registrations since the name is never used directly.

Practical example: memoized API client

Use lru_cache to cache API responses and avoid redundant network calls. The print and sleep inside the function simulate the cost of a real network round trip.

pythonpython
from functools import lru_cache
import time
 
@lru_cache(maxsize=100)
def fetch_user(user_id: int) -> dict:
    print(f"Fetching user {user_id} from API...")
    time.sleep(0.1)
    return {"id": user_id, "name": f"User_{user_id}"}

Call fetch_user with a repeated id mixed in among new ones, then inspect the cache statistics to see how many calls actually hit the network:

pythonpython
print(fetch_user(1))
print(fetch_user(2))
print(fetch_user(1))
print(fetch_user.cache_info())

The "Fetching..." message only prints for the first two calls; the third call for user 1 returns straight from the cache with no network simulation at all:

texttext
Fetching user 1 from API...
{'id': 1, 'name': 'User_1'}
Fetching user 2 from API...
{'id': 2, 'name': 'User_2'}
{'id': 1, 'name': 'User_1'}
CacheInfo(hits=1, misses=2, maxsize=100, currsize=2)

The second call to fetch_user(1) returns instantly from the cache. The cache stores up to 100 unique users before evicting old entries.

Common mistakes

Using lru_cache on functions with unhashable arguments. lru_cache requires all arguments to be hashable. Lists and dicts are not hashable and will cause a TypeError. Convert them to tuples or frozensets before calling.

Forgetting maxsize on lru_cache. lru_cache() without arguments defaults to maxsize=128. If your function takes many unique inputs, set maxsize appropriately or use cache() for unlimited caching.

Using reduce when a built-in is clearer. sum(numbers), max(numbers), any(flags), all(flags), and " ".join(words) are more readable than the equivalent reduce calls. Use reduce only when no dedicated built-in covers your use case.

Forgetting @wraps in decorators. Every decorator that returns a new function should use @wraps(func). Without it, debugging tools, documentation generators, and introspection all see the wrapper instead of the original function.

Rune AI

Rune AI

Key Insights

  • functools.cache(func) memoizes all calls for unlimited caching (Python 3.9+).
  • functools.lru_cache(maxsize=128) caches with an upper bound, evicting least recently used entries.
  • functools.partial(func, arg) creates a new function with some arguments pre-filled.
  • functools.reduce(func, iterable) combines items into a single result.
  • functools.wraps(func) preserves a decorated function's name, docstring, and metadata.
  • functools.singledispatch creates functions that behave differently based on the first argument's type.
RunePowered by Rune AI

Frequently Asked Questions

When should I use lru_cache vs cache?

Use `functools.cache` (Python 3.9+) for unbounded memoization where you want all results cached. Use `functools.lru_cache(maxsize=128)` when memory is a concern and you want to limit the cache size. `lru_cache` evicts the least recently used entries when the cache is full.

How does functools.partial differ from a lambda?

`partial(func, x=1)` is more explicit and introspectable than `lambda y: func(1, y)`. `partial` preserves the original function's name, docstring, and module, making debugging easier. Use `partial` when you are fixing arguments of an existing function; use `lambda` for simple one-off expressions.

Can I use functools.cache on methods?

Yes, but be aware that `self` is part of the cache key. Each instance gets its own cache because `self` is different. For class-level caching that shares across instances, use a class-level dictionary or `lru_cache` on a static method.

Conclusion

functools is the toolkit for working with functions as values in Python. Use lru_cache and cache to speed up repeated calls, partial to fix arguments of existing functions, reduce to combine iterables into a single value, and wraps to preserve metadata in decorators. These tools let you write cleaner, more composable code without reinventing common patterns.