DEV Community

Kai Thorne
Kai Thorne

Posted on

Python functools: 6 Decorators and Utilities That Will Simplify Your Code

Python functools: 6 Decorators and Utilities That Will Simplify Your Code

If you've been writing Python for a while, you've probably used @staticmethod or @property. But Python's standard library has a hidden gem — the functools module — that gives you even more powerful tools for writing cleaner, faster, and more maintainable code.

In my last few articles, I covered itertools and the collections module. Today we're tackling functools — the module every Python developer should know but many don't reach for often enough.

Let's dive into the six functools tools I use most in production code.


1. @lru_cache — Memoization Made Trivial

The single most useful functools utility. @lru_cache stores the results of expensive function calls and returns the cached result when the same arguments occur again.

from functools import lru_cache

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

# First call computes, subsequent calls are O(1)
print(fibonacci(100))  # 354224848179261915075 — nearly instant
Enter fullscreen mode Exit fullscreen mode

Without lru_cache, fibonacci(100) would never finish. With it, each unique n is computed once. The maxsize parameter controls how many results to keep (use maxsize=None for unlimited caching).

Real-world use case: Expensive database lookups

@lru_cache(maxsize=256)
def get_user_permissions(user_id: int) -> list[str]:
    """Expensive DB/compute call — cached aggressively."""
    result = db.query("SELECT permission FROM user_permissions WHERE user_id = ?", user_id)
    return [row[0] for row in result]
Enter fullscreen mode Exit fullscreen mode

cache_info() gives you hit/miss stats:

print(fibonacci.cache_info())
# CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)
Enter fullscreen mode Exit fullscreen mode

Pro tip: Use cache_clear() for testing, or when cached data goes stale.


2. @cache — The Simpler Sibling (Python 3.9+)

Python 3.9 introduced @cache as a shorthand for @lru_cache(maxsize=None):

from functools import cache

@cache
def load_config(filepath: str) -> dict:
    with open(filepath) as f:
        return json.load(f)
Enter fullscreen mode Exit fullscreen mode

Same behavior as lru_cache, but unlimited. Use this when you know the input domain is small or bounded.


3. @singledispatch — Clean Function Overloading

Python doesn't have method overloading like Java, but @singledispatch gives you the next best thing — single-dispatch generic functions.

from functools import singledispatch

@singledispatch
def process(data):
    """Default handler — raises for unknown types."""
    raise TypeError(f"No handler for {type(data).__name__}")

@process.register(str)
def _(data: str):
    return f"String: {data.upper()}"

@process.register(list)
def _(data: list):
    return [item * 2 for item in data]

@process.register(dict)
def _(data: dict):
    return {k: v * 3 for k, v in data.items()}

# Usage
print(process("hello"))     # String: HELLO
print(process([1, 2, 3]))   # [2, 4, 6]
print(process({"a": 1}))    # {'a': 3}
Enter fullscreen mode Exit fullscreen mode

This is much cleaner than a chain of isinstance() checks and makes it easy to add support for new types without modifying existing code.

When to use it:

  • Processing different data formats (JSON, CSV, XML)
  • Serialization/deserialization pipelines
  • Plugin-like architectures where different types need different handling

4. @wraps — Preserving Function Metadata

Ever decorated a function and lost its name, docstring, or signature? That's where @wraps comes in.

from functools import wraps

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

@retry(max_attempts=5)
def fetch_data(url: str) -> dict:
    """Fetch JSON data from a URL with retry logic."""
    ...

print(fetch_data.__name__)  # 'fetch_data' — preserved!
print(fetch_data.__doc__)   # 'Fetch JSON data from a URL with retry logic.'
Enter fullscreen mode Exit fullscreen mode

Without @wraps, fetch_data.__name__ would return 'wrapper' and you'd lose introspection. Always use @wraps in your decorators — it's cheap and prevents confusing bugs.


5. partial — Freeze Function Arguments

partial lets you "pre-fill" arguments of a function, creating a new callable with fewer parameters.

from functools import partial

def power(base: float, exponent: float) -> float:
    return base ** exponent

square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(5))   # 25
print(cube(5))     # 125
Enter fullscreen mode Exit fullscreen mode

Practical example: Configurable loggers

import logging

def create_logger(name: str, level: int = logging.INFO):
    logger = logging.getLogger(name)
    logger.setLevel(level)
    handler = logging.StreamHandler()
    handler.setFormatter(logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    ))
    logger.addHandler(handler)
    return logger

# Pre-configure loggers for different modules
api_logger = partial(create_logger, "api", level=logging.DEBUG)
db_logger = partial(create_logger, "database", level=logging.WARNING)
worker_logger = partial(create_logger, "worker")

# When you need them:
log = api_logger()
log.debug("This appears in API logs")
Enter fullscreen mode Exit fullscreen mode

Combined with map:

# Instead of a lambda:
results = map(lambda x: power(x, 2), range(10))

# Use partial:
results = map(partial(power, exponent=2), range(10))
Enter fullscreen mode Exit fullscreen mode

partial is often cleaner and more readable than a lambda, especially when used as a callback or in functional pipelines.


6. reduce — Accumulate Values Sequentially

reduce was moved to functools in Python 3 (it was a built-in in Python 2). It applies a function cumulatively to items in a sequence, reducing it to a single value.

from functools import reduce

# Multiply all numbers in a list
numbers = [1, 2, 3, 4, 5]
product = reduce(lambda a, b: a * b, numbers)
print(product)  # 120
Enter fullscreen mode Exit fullscreen mode

Practical: Flatten nested structures

def merge_dicts(a: dict, b: dict) -> dict:
    """Merge two dicts, summing overlapping values."""
    result = a.copy()
    for key, value in b.items():
        result[key] = result.get(key, 0) + value
    return result

transactions = [
    {"apple": 3, "banana": 2},
    {"apple": 1, "orange": 5},
    {"banana": 4, "orange": 1},
]

total = reduce(merge_dicts, transactions)
print(total)  # {'apple': 4, 'banana': 6, 'orange': 6}
Enter fullscreen mode Exit fullscreen mode

When NOT to use reduce:

Sometimes a simple loop is more readable. David Beazley once said: "reduce is fine, but 90% of the time an explicit for loop is better." Use your judgment.


Bonus: total_ordering — Complete Your Comparison Operators

Writing __eq__ and all six comparison operators (__lt__, __le__, __gt__, __ge__) is tedious. @total_ordering fills in the gaps if you define just __eq__ and one other:

from functools import total_ordering

@total_ordering
class Priority:
    def __init__(self, level: int):
        self.level = level

    def __eq__(self, other):
        return self.level == other.level

    def __lt__(self, other):
        return self.level < other.level

# Now all comparisons work:
p1 = Priority(1)
p2 = Priority(2)
print(p1 <= p2)  # True — auto-generated
print(p1 >= p2)  # False — auto-generated
Enter fullscreen mode Exit fullscreen mode

This reduces boilerplate without magic — functools generates the missing operators for you.


Putting It All Together

Here's a production example combining multiple functools utilities:

from functools import cache, wraps, partial
from typing import Any
import time

def timed(func):
    """Decorator that logs execution time."""
    @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:.3f}s")
        return result
    return wrapper

@cache
def get_config() -> dict:
    """Load app config from file (cached after first call)."""
    with open("config.json") as f:
        return json.load(f)

@timed
def compute_discounts(prices: list[float], rate: float) -> list[float]:
    discount = partial(lambda p, r: p * (1 - r), rate=rate)
    return list(map(discount, prices))

# Clean, readable, fast — all thanks to functools
Enter fullscreen mode Exit fullscreen mode

Cheat Sheet

Function Purpose Python Version
@lru_cache Memoization with size limit 3.2+
@cache Unlimited memoization 3.9+
@singledispatch Type-based dispatch 3.4+
@wraps Preserve metadata in decorators 3.2+
partial Freeze function arguments 3.2+
reduce Sequential accumulation 3.2+ (moved to functools)
@total_ordering Auto-generate comparison ops 3.2+

What's Next?

If you found this useful, you might also enjoy my guide on the collections module or the itertools deep dive.

And if you're writing Python decorators or functional code regularly, I've put together a collection of Python automation patterns and AI coding prompts that covers these patterns and many more in ready-to-use templates.


What's your favorite functools utility? I'm partial to lru_cache — it's solved more performance problems for me than any other single line of code.

Top comments (0)