Skip to content
Codeloom
Python

Python functools: partial, lru_cache, reduce, and More

Master Python's functools module with practical examples of partial, lru_cache, reduce, singledispatch, cached_property, and wraps.

·8 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • Using partial to create specialized functions
  • Caching expensive computations with lru_cache
  • Implementing function dispatch with singledispatch
  • Applying reduce, wraps, and total_ordering

Prerequisites

  • Basic Python functions and decorators
  • Understanding of closures
  • Familiarity with iterables

Why functools Matters

The functools module provides higher-order functions and operations on callable objects. It bridges the gap between Python’s object-oriented nature and functional programming patterns. Instead of writing boilerplate code for caching, dispatching, or adapting functions, functools gives you battle-tested tools.

functools.partial: Fix Function Arguments

partial creates a new function by freezing some arguments of an existing function:

from functools import partial

def power(base, exponent):
    return base ** exponent

# Create specialized versions
square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(5))  # 25
print(cube(3))    # 27

# Useful with higher-order functions
numbers = [1, 2, 3, 4, 5]
squares = list(map(square, numbers))
print(squares)  # [1, 4, 9, 16, 25]

A practical example with logging:

from functools import partial
import logging

def log_message(level, component, message):
    logging.log(level, f"[{component}] {message}")

# Create component-specific loggers
db_info = partial(log_message, logging.INFO, "database")
db_error = partial(log_message, logging.ERROR, "database")
api_info = partial(log_message, logging.INFO, "api")

db_info("Connection established")
db_error("Query timeout after 30s")
api_info("Request received at /users")

partial is cleaner than lambdas for this purpose and plays nicely with pickle, multiprocessing, and debugging (it preserves the function name and arguments).

from functools import partial

# Partial objects are inspectable
square = partial(power, exponent=2)
print(square.func)      # <function power at ...>
print(square.args)      # ()
print(square.keywords)  # {'exponent': 2}

functools.lru_cache: Memoize Function Results

lru_cache caches function return values based on the arguments. Subsequent calls with the same arguments return the cached result instantly:

from functools import lru_cache

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

# Without cache: exponential time
# With cache: linear time
print(fibonacci(100))  # 354224848179261915075

# Inspect cache statistics
print(fibonacci.cache_info())
# CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)

# Clear the cache
fibonacci.cache_clear()

Use maxsize=None for an unbounded cache (useful when you know the input space is finite):

from functools import lru_cache

@lru_cache(maxsize=None)
def count_paths(m, n):
    """Count paths in an m x n grid (top-left to bottom-right)."""
    if m == 1 or n == 1:
        return 1
    return count_paths(m - 1, n) + count_paths(m, n - 1)

print(count_paths(20, 20))  # 35345263800

Important caveat: arguments must be hashable. Lists, dicts, and sets cannot be used as arguments to cached functions.

from functools import lru_cache

@lru_cache
def process(items):
    return sum(items)

# Works with tuples (hashable)
process((1, 2, 3))

# Fails with lists (unhashable)
try:
    process([1, 2, 3])
except TypeError as e:
    print(e)  # unhashable type: 'list'

functools.cache (Python 3.9+)

cache is a simpler alias for lru_cache(maxsize=None):

from functools import cache

@cache
def expensive_computation(x, y):
    print(f"Computing {x} + {y}")
    return x + y

expensive_computation(1, 2)  # Computing 1 + 2 -> 3
expensive_computation(1, 2)  # Returns 3 without printing

functools.cached_property (Python 3.8+)

cached_property transforms a method into a property that is computed once and then cached as an instance attribute:

from functools import cached_property
import statistics

class Dataset:
    def __init__(self, values):
        self._values = list(values)

    @cached_property
    def stats(self):
        print("Computing statistics...")
        return {
            "mean": statistics.mean(self._values),
            "median": statistics.median(self._values),
            "stdev": statistics.stdev(self._values),
        }

    @cached_property
    def sorted_values(self):
        print("Sorting values...")
        return sorted(self._values)

data = Dataset([45, 23, 67, 12, 89, 34, 56])
print(data.stats)          # Computing statistics... {dict}
print(data.stats)          # Returns cached result
print(data.sorted_values)  # Sorting values... [12, 23, ...]

Unlike lru_cache, cached_property works per instance and the cached value lives in the instance __dict__. You can invalidate the cache by deleting the attribute:

del data.stats       # Remove cached value
print(data.stats)    # Recomputes

functools.reduce: Cumulative Operations

reduce applies a two-argument function cumulatively to the items of a sequence:

from functools import reduce

# Sum of all numbers (equivalent to sum())
total = reduce(lambda a, b: a + b, [1, 2, 3, 4, 5])
print(total)  # 15

# Product of all numbers
product = reduce(lambda a, b: a * b, [1, 2, 3, 4, 5])
print(product)  # 120

# Find the longest string
words = ["Python", "is", "a", "wonderful", "language"]
longest = reduce(lambda a, b: a if len(a) >= len(b) else b, words)
print(longest)  # wonderful

reduce accepts an optional initial value:

from functools import reduce

# Flatten a list of lists
nested = [[1, 2], [3, 4], [5, 6]]
flat = reduce(lambda acc, lst: acc + lst, nested, [])
print(flat)  # [1, 2, 3, 4, 5, 6]

# Build a dictionary from pairs
pairs = [("a", 1), ("b", 2), ("c", 3)]
result = reduce(lambda d, pair: {**d, pair[0]: pair[1]}, pairs, {})
print(result)  # {'a': 1, 'b': 2, 'c': 3}

A practical example that composes functions:

from functools import reduce

def compose(*functions):
    """Compose multiple functions: compose(f, g, h)(x) = f(g(h(x)))"""
    return reduce(lambda f, g: lambda x: f(g(x)), functions)

double = lambda x: x * 2
increment = lambda x: x + 1
square = lambda x: x ** 2

transform = compose(double, increment, square)
print(transform(3))  # double(increment(square(3))) = double(10) = 20

functools.wraps: Preserve Function Metadata

When you write decorators, the wrapper function replaces the original. wraps copies the original function’s metadata to the wrapper:

from functools import wraps
import time

# Without wraps: metadata is lost
def timer_bad(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time() - start:.4f}s")
        return result
    return wrapper

# With wraps: metadata is preserved
def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time() - start:.4f}s")
        return result
    return wrapper

@timer
def fetch_data(url):
    """Fetch data from a URL."""
    time.sleep(0.1)
    return f"data from {url}"

print(fetch_data.__name__)  # fetch_data (not 'wrapper')
print(fetch_data.__doc__)   # Fetch data from a URL.

Without @wraps, fetch_data.__name__ would be "wrapper", which breaks introspection tools, documentation generators, and debugging.

functools.singledispatch: Type-Based Dispatch

singledispatch turns a function into a generic function that dispatches on the type of its first argument:

from functools import singledispatch

@singledispatch
def serialize(obj):
    raise TypeError(f"Cannot serialize {type(obj)}")

@serialize.register(str)
def _(obj):
    return f'"{obj}"'

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

@serialize.register(float)
def _(obj):
    return f"{obj:.2f}"

@serialize.register(list)
def _(obj):
    items = ", ".join(serialize(item) for item in obj)
    return f"[{items}]"

@serialize.register(dict)
def _(obj):
    pairs = ", ".join(f"{serialize(k)}: {serialize(v)}" for k, v in obj.items())
    return "{" + pairs + "}"


print(serialize("hello"))           # "hello"
print(serialize(42))                # 42
print(serialize(3.14159))           # 3.14
print(serialize([1, "two", 3.0]))   # [1, "two", 3.00]
print(serialize({"a": 1}))          # {"a": 1}

You can also use type annotations (Python 3.7+):

from functools import singledispatch
from datetime import datetime, date

@singledispatch
def format_value(value):
    return str(value)

@format_value.register
def _(value: datetime):
    return value.strftime("%Y-%m-%d %H:%M:%S")

@format_value.register
def _(value: date):
    return value.strftime("%Y-%m-%d")

@format_value.register
def _(value: float):
    return f"{value:,.2f}"

print(format_value(datetime(2026, 7, 6, 14, 30)))  # 2026-07-06 14:30:00
print(format_value(1234567.89))                      # 1,234,567.89

For methods, use singledispatchmethod (Python 3.8+):

from functools import singledispatchmethod

class Formatter:
    @singledispatchmethod
    def format(self, value):
        return str(value)

    @format.register
    def _(self, value: int):
        return f"Integer: {value}"

    @format.register
    def _(self, value: list):
        return f"List with {len(value)} items"

fmt = Formatter()
print(fmt.format(42))         # Integer: 42
print(fmt.format([1, 2, 3]))  # List with 3 items

functools.total_ordering: Complete Comparison Methods

Define __eq__ and one other comparison method, and total_ordering fills in the rest:

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major, minor, patch):
        self.major = major
        self.minor = minor
        self.patch = patch

    def __eq__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)

    def __lt__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)

    def __repr__(self):
        return f"Version({self.major}.{self.minor}.{self.patch})"


v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 3)

print(v1 < v2)   # True
print(v1 <= v3)   # True (generated by total_ordering)
print(v2 > v1)   # True (generated by total_ordering)
print(v1 >= v3)   # True (generated by total_ordering)
print(v1 == v3)  # True

versions = [Version(2, 0, 0), Version(1, 5, 3), Version(1, 2, 0)]
print(sorted(versions))  # [Version(1.2.0), Version(1.5.3), Version(2.0.0)]

functools.cmp_to_key: Legacy Sort Functions

Convert an old-style comparison function to a key function for sorted:

from functools import cmp_to_key

def compare_versions(a, b):
    """Old-style comparison: returns negative, zero, or positive."""
    for a_part, b_part in zip(a.split("."), b.split(".")):
        diff = int(a_part) - int(b_part)
        if diff != 0:
            return diff
    return 0

versions = ["1.10.2", "1.2.3", "2.0.0", "1.10.1"]
sorted_versions = sorted(versions, key=cmp_to_key(compare_versions))
print(sorted_versions)  # ['1.2.3', '1.10.1', '1.10.2', '2.0.0']

Combining functools Tools

These tools work well together:

from functools import lru_cache, partial, reduce, wraps

def retry(max_attempts=3):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"Attempt {attempt} failed: {e}")
        return wrapper
    return decorator

@retry(max_attempts=3)
@lru_cache(maxsize=64)
def fetch_config(key):
    """Fetch configuration value with retry and caching."""
    import random
    if random.random() < 0.3:
        raise ConnectionError("Network timeout")
    return f"value_for_{key}"

# The decorator order matters: retry wraps the cached function
result = fetch_config("database_url")
print(result)
print(fetch_config.__name__)  # fetch_config (preserved by @wraps)

Wrapping Up

The functools module is one of Python’s most practical standard library modules. partial lets you create specialized functions without lambdas. lru_cache and cached_property add memoization with minimal code. singledispatch provides clean type-based dispatch. reduce enables cumulative operations. wraps preserves metadata across decorators. And total_ordering saves you from writing repetitive comparison methods. These tools reduce boilerplate and make your code more expressive, readable, and efficient.