Python functools: wraps & lru_cache Decorators
The functools module provides powerful tools for working with higher-order functions and decorators. This guide covers two essential decorators: @functools.wraps, which preserves function metadata when creating decorators, and @functools.lru_cache, which adds automatic memoization to cache expensive function results. Using these tools correctly prevents metadata loss in decorated functions and provides dramatic performance improvements for recursive and frequently-called functions—Fibonacci acceleration from seconds to microseconds is typical.
Why Decorators Break Function Metadata: The Problem
When you create a decorator, you replace the original function with a wrapper function. This causes the original function's name, docstring, and other metadata (__name__, __doc__, __annotations__) to be lost. Consider this broken example:
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Decorator logic here...")
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""Greets a person by name."""
return f"Hello, {name}!"
print(greet.__name__) # Output: wrapper (WRONG! Should be 'greet')
print(greet.__doc__) # Output: None (WRONG! Should be the docstring)
Without @functools.wraps, debugging tools, documentation generators, and your IDE lose essential information about the original function. This is especially problematic in libraries where introspection is important.
Using @functools.wraps to Preserve Metadata
@functools.wraps is a decorator you apply to your wrapper function. It copies the __name__, __doc__, __module__, __qualname__, and __annotations__ attributes from the original function to the wrapper, making the decorated function behave as if it were never wrapped.
The Correct Decorator Pattern
import functools
def my_decorator(func):
@functools.wraps(func) # <--- Apply this decorator to wrapper
def wrapper(*args, **kwargs):
print("Decorator logic here...")
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name: str) -> str:
"""Greets a person by name."""
return f"Hello, {name}!"
print(greet.__name__) # Output: greet (CORRECT)
print(greet.__doc__) # Output: Greets a person by name. (CORRECT)
print(greet.__annotations__) # Output: {'name': <class 'str'>, 'return': <class 'str'>}
Now the decorated function preserves all original metadata. This is essential for:
- Debugging: Stack traces and error messages show the original function name.
- Documentation generators: Tools like Sphinx can extract docstrings correctly.
- Type hints: IDEs use
__annotations__for autocompletion and type checking. - Introspection: Code that inspects functions (frameworks, libraries) works correctly.
Rule of Thumb
Always use @functools.wraps when writing a decorator. It takes one line and prevents subtle bugs in debugging and documentation. Omitting it is a common mistake that creates technical debt.
Memoization: Caching Function Results for Performance
Memoization is an optimization technique where you store (cache) the results of expensive function calls and return the cached result when the function is called again with the same arguments. This eliminates redundant computation, transforming slow functions into nearly-instant lookups.
The Problem: Redundant Computation
Consider a recursive Fibonacci function without memoization:
def fibonacci(n):
"""Calculates the nth Fibonacci number recursively."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
Computing fibonacci(35) requires millions of redundant calculations because the same values are computed over and over:
fibonacci(5)is calculated multiple timesfibonacci(4)is calculated multiple times- This exponential duplication makes the naive approach extremely slow
@functools.lru_cache: One-Line Automatic Memoization
@functools.lru_cache implements memoization automatically. "LRU" stands for Least Recently Used, meaning when the cache reaches its maximum size, the least recently used entries are discarded to make room for new ones.
Syntax
@functools.lru_cache(maxsize=128)
def expensive_function(x):
# Function implementation
pass
Parameters:
maxsize: Maximum number of cached results. Set toNonefor unlimited caching (use only if you know the input domain is finite and small). Default is 128.typed: IfTrue, arguments of different types are cached separately (e.g.,f(3)andf(3.0)are cached separately). Default isFalse.
Real-World Example: Accelerating Fibonacci 1000x
Without memoization — SLOW:
import time
def fibonacci(n):
"""Calculates the nth Fibonacci number recursively (very slow)."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
start = time.time()
result = fibonacci(35)
elapsed = time.time() - start
print(f"Result: {result}")
print(f"Time without cache: {elapsed:.3f} seconds")
On a typical machine, fibonacci(35) takes 5-10 seconds because it recalculates the same values millions of times.
With @lru_cache — FAST:
import functools
import time
@functools.lru_cache(maxsize=None)
def fibonacci_cached(n):
"""Calculates the nth Fibonacci number with caching (very fast)."""
if n < 2:
return n
return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)
start = time.time()
result = fibonacci_cached(35)
elapsed = time.time() - start
print(f"Result: {result}")
print(f"Time with cache: {elapsed:.6f} seconds")
The cached version completes in milliseconds (typically less than 0.001 seconds). The first call to fibonacci_cached(2) computes and stores the result. Every subsequent call to fibonacci_cached(2) retrieves the cached value in nanoseconds instead of recomputing.
Inspecting Cache Performance
@lru_cache adds a .cache_info() method that reveals caching statistics:
print(fibonacci_cached.cache_info())
Output:
CacheInfo(hits=33, misses=36, maxsize=None, currsize=36)
- hits: Number of cache hits (cached values retrieved). The cache saved 33 redundant computations.
- misses: Number of cache misses (values computed and cached for the first time).
- currsize: Current number of items in the cache.
You can also clear the cache:
fibonacci_cached.cache_clear()
When to Use @lru_cache: Ideal Use Cases
@lru_cache is perfect for:
- Recursive functions: Fibonacci, factorials, tree traversals where many subproblems are repeated.
- Expensive computations: Database queries, API calls, complex calculations that take milliseconds or longer.
- Pure functions: Functions that always return the same output for the same input. Do NOT use on functions with side effects (file I/O, network requests) unless you cache deterministically.
- Frequently-called functions: Functions called thousands of times with a limited set of arguments.
Do NOT use for:
- Non-deterministic functions (functions with side effects or randomness).
- Functions where arguments are mutable (lists, dictionaries) or unhashable.
- Functions that depend on external state that changes.
Practical Example: Caching an Expensive API Simulation
import functools
import time
@functools.lru_cache(maxsize=32)
def fetch_user_data(user_id: int) -> dict:
"""Simulates fetching user data from an API."""
print(f"Fetching user {user_id} from API (slow operation)...")
time.sleep(0.5) # Simulate network latency
return {
"id": user_id,
"name": f"User {user_id}",
"email": f"user{user_id}@example.com"
}
# First call: cache miss, slow
print(fetch_user_data(1)) # Prints "Fetching...", takes 0.5s
print(fetch_user_data.__name__) # Output: fetch_user_data
# Second call: cache hit, fast
print(fetch_user_data(1)) # No "Fetching..." message, instant
# Different user: cache miss
print(fetch_user_data(2)) # Prints "Fetching...", takes 0.5s
print(fetch_user_data.cache_info())
# Output: CacheInfo(hits=1, misses=2, maxsize=32, currsize=2)
Combining @wraps and @lru_cache
You can stack decorators. The order matters—apply @lru_cache first (innermost):
import functools
def timing_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.6f}s")
return result
return wrapper
@timing_decorator
@functools.lru_cache(maxsize=128)
def fibonacci_timed(n):
if n < 2:
return n
return fibonacci_timed(n - 1) + fibonacci_timed(n - 2)
fibonacci_timed(30) # First call: slow, cache misses
fibonacci_timed(30) # Second call: fast, cache hits
The @lru_cache provides the performance benefit, and @timing_decorator with @wraps preserves the function name for timing output.
Key Takeaways
@functools.wrapsis mandatory for any decorator you write. It preserves__name__,__doc__,__annotations__, and other metadata from the original function to the wrapper.- Memoization is a powerful optimization technique that caches expensive function results, eliminating redundant computation.
@functools.lru_cacheimplements memoization automatically. It can accelerate recursive functions by orders of magnitude (1000x for Fibonacci is typical).- Cache statistics: Use
.cache_info()and.cache_clear()to inspect and manage cache behavior. - Stacking decorators: Place
@lru_cachebefore other decorators (innermost first) so caching works correctly. - Use carefully: Only cache deterministic functions with hashable arguments. Do not cache functions with side effects or non-deterministic behavior.
Frequently Asked Questions
What is the difference between @wraps and @lru_cache?
@wraps is a metadata decorator—it preserves function information but does not change behavior. @lru_cache is a performance decorator—it caches results and changes behavior by returning cached values. They serve different purposes and can be used together.
Can I use @lru_cache on methods inside a class?
As of Python 3.8, yes, but use @functools.cached_property for instance methods if you want to cache the result once per instance. For regular methods, @lru_cache works but caches across all instances, which may not be intended.
What happens when the cache is full?
The least recently used items are evicted (discarded). This keeps memory usage bounded. Set maxsize=None only for small, finite input domains.
Can I use @lru_cache on functions with mutable arguments like lists?
No. Lists and dictionaries are unhashable, so they cannot be used as cache keys. Use tuples (immutable) instead: convert @lru_cache functions to accept tuples, or use a custom caching solution.
How much faster is memoization compared to recomputation?
It depends on the function cost. For Fibonacci, speedups of 1000x+ are typical. For lightweight functions, the overhead of cache lookup may outweigh the benefit. Always profile before and after adding @lru_cache.