Skip to content
Codeloom
Python

Python functools Deep Dive: Beyond lru_cache

Explore the functools module beyond caching. Learn partial, reduce, singledispatch, cached_property, and total_ordering with practical examples.

·5 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Using partial to create specialized functions from general ones
  • Implementing function overloading with singledispatch
  • Leveraging cached_property for expensive computed attributes
  • Writing comparable classes with total_ordering

Prerequisites

None — this post is self-contained.

Most Python developers know functools.lru_cache. But the functools module contains a collection of higher-order functions and utilities that solve real problems elegantly. This article covers the ones you should know beyond basic caching.

functools.partial — Fix Some Arguments

partial creates a new function with some arguments pre-filled. This is cleaner than writing a lambda or a wrapper function.

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(3))    # 27

Practical Use: Configuring Callbacks

partial shines when you need to pass a callback that expects fewer arguments than you have context for:

from functools import partial
import logging

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

# Create specialized loggers
db_warning = partial(log_event, logging.WARNING, "database")
api_error = partial(log_event, logging.ERROR, "api")

db_warning("Connection pool exhausted")
api_error("Timeout on /users endpoint")

partial vs lambda

Both work, but partial is more inspectable and picklable:

from functools import partial

# Lambda
f = lambda x: power(x, 2)
print(f)  # <function <lambda> at 0x...>

# Partial
g = partial(power, exponent=2)
print(g)  # functools.partial(<function power>, exponent=2)
print(g.func)  # <function power>
print(g.keywords)  # {'exponent': 2}

functools.singledispatch — Function Overloading

Python does not support function overloading by type signature like Java does. singledispatch gives you the next best thing: dispatch based on the type of the first argument.

from functools import singledispatch

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

@format_value.register(int)
def _(value: int) -> str:
    return f"{value:,}"

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

@format_value.register(list)
def _(value: list) -> str:
    return f"[{len(value)} items]"

print(format_value(1000000))   # "1,000,000"
print(format_value(3.14159))   # "3.14"
print(format_value([1, 2, 3])) # "[3 items]"
print(format_value("hello"))   # "hello" (falls through to base)

singledispatchmethod for Classes

For methods, use singledispatchmethod:

from functools import singledispatchmethod

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

    @format.register(dict)
    def _(self, value: dict) -> str:
        pairs = ", ".join(f"{k}={v}" for k, v in value.items())
        return f"{{{pairs}}}"

functools.reduce — Accumulate a Sequence

reduce applies a two-argument function cumulatively to sequence elements. It was a builtin in Python 2 and moved to functools in Python 3.

from functools import reduce
from operator import mul

# Product of a list
numbers = [2, 3, 4, 5]
product = reduce(mul, numbers)
print(product)  # 120

# Flatten nested 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 nested dictionary access
def deep_get(d, *keys):
    return reduce(lambda obj, key: obj[key], keys, d)

config = {"db": {"primary": {"host": "localhost", "port": 5432}}}
print(deep_get(config, "db", "primary", "host"))  # "localhost"

Use reduce sparingly. A for loop is often clearer. But for operations like computing products or composing functions, it is the right tool.

functools.cached_property — Lazy Computed Attributes

cached_property computes a value once and caches it as an instance attribute. Unlike @property with manual caching, it is thread-safe and clean.

from functools import cached_property
import time

class DataAnalyzer:
    def __init__(self, data: list[float]):
        self._data = data

    @cached_property
    def statistics(self) -> dict:
        """Expensive computation done only once."""
        time.sleep(1)  # Simulate heavy work
        n = len(self._data)
        mean = sum(self._data) / n
        variance = sum((x - mean) ** 2 for x in self._data) / n
        return {
            "mean": mean,
            "variance": variance,
            "std_dev": variance ** 0.5,
            "count": n,
        }

analyzer = DataAnalyzer([1.0, 2.0, 3.0, 4.0, 5.0])
print(analyzer.statistics)  # Takes 1 second
print(analyzer.statistics)  # Instant -- cached

To invalidate the cache, delete the attribute:

del analyzer.statistics  # Next access recomputes

Note: cached_property only works on instances with a __dict__. It will not work with __slots__.

functools.total_ordering — Complete Comparison Methods

If your class defines __eq__ and one of __lt__, __le__, __gt__, or __ge__, total_ordering fills in the rest:

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major: int, minor: int, patch: int):
        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, 0)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 0)

print(v1 < v2)   # True
print(v1 >= v3)   # True (generated by total_ordering)
print(v2 <= v1)   # False (generated by total_ordering)
print(sorted([v2, v1, v3]))  # [Version(1.2.0), Version(1.2.0), Version(1.3.0)]

functools.wraps — Preserve Function Metadata

When writing decorators, wraps copies the decorated function’s name, docstring, and other metadata onto the wrapper:

from functools import wraps
import time

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

@timer
def slow_function():
    """Does something slowly."""
    time.sleep(0.1)

print(slow_function.__name__)  # "slow_function", not "wrapper"
print(slow_function.__doc__)   # "Does something slowly."

Without @wraps, debugging and documentation tools see “wrapper” instead of the original function name.

Key Takeaways

The functools module gives you battle-tested tools for common functional programming patterns: fixing arguments with partial, dispatching by type with singledispatch, lazy computation with cached_property, and painless comparison with total_ordering. Before writing custom boilerplate, check whether functools already solves your problem.