Memoization Made Simple: Using functools.lru_cache in Production
I often run into situations where a pure function is called repeatedly with the same arguments—calculating tax rates for different regions, querying a static lookup table, or recursively solving a combinatorial problem. In those moments the overhead of recomputation adds up quickly, and the code becomes slower than it should be. The standard library already provides a practical answer: functools.lru_cache. This decorator stores the results of recent calls and returns them when the same inputs appear again, effectively turning an expensive calculation into an O(1) lookup after the first invocation.
Real‑World Scenario
Imagine a delivery service that needs to compute the shipping cost for a package. The cost depends on three variables: origin zip code, destination zip code, and the weight class. The calculation is deterministic and can be expressed as a pure function. In a high‑traffic environment, the same origin‑destination pair may be queried many times per second as users browse rates. Without caching, each request would trigger a database lookup and a series of arithmetic operations. By decorating the cost function with @lru_cache, the first request populates the cache, and subsequent requests retrieve the result instantly. The result is a smoother user experience and lower database load.
Why lru_cache Works
The decorator implements a Least‑Recently‑Used (LRU) eviction policy. When the cache reaches its size limit it discards the entry that hasn't been accessed for the longest time, keeping the most relevant results in memory. This behavior is ideal for workloads where recent calls are more likely to repeat than older ones. The cache is thread‑safe by default, so you can safely share a cached function across multiple threads without additional synchronization.
Beyond simple caching, lru_cache also provides metadata you can inspect—cache_info() returns hit, miss, and eviction counts, which are invaluable for performance debugging. Moreover, the cache can be cleared with a single method call, giving you control over memory usage when dealing with long‑running processes.
Putting It All Together
Below is a production‑ready example that demonstrates the pattern in a realistic setting. The code includes type hints, a docstring, and a simple test harness that you can drop into a Python project.
import functools
from typing import Dict, Tuple
# Define a simple weight class mapping
_WEIGHT_CLASSES: Dict[float, str] = {
0.5: "light",
2.0: "medium",
10.0: "heavy",
}
@functools.lru_cache(maxsize=128)
def shipping_cost(origin: str, destination: str, weight: float) -> float:
"""Calculate shipping cost based on origin, destination and weight.
This is a pure function – identical inputs always produce identical output.
Decorating it with @lru_cache makes repeated calls with the same parameters
essentially free after the first computation.
"""
# Simulate a database lookup for the base rate per km
base_rate_per_km = _fetch_base_rate(origin, destination)
# Determine weight class
weight_class = next(
(cls for limit, cls in _WEIGHT_CLASSES.items() if weight <= limit),
"unknown",
)
# Complex business logic – expensive enough to merit caching
distance = _calculate_distance(origin, destination)
multiplier = _apply_business_rules(weight_class, distance)
return base_rate_per_km * distance * multiplier
# Helper functions that simulate external calls
def _fetch_base_rate(origin: str, destination: str) -> float:
# In a real app this would hit a DB or an API
return 0.42
def _calculate_distance(origin: str, destination: str) -> float:
# Stub – return a deterministic distance for demo
return 123.5
def _apply_business_rules(weight_class: str, distance: float) -> float:
# Stub – arbitrary rule set
rules = {"light": 1.0, "medium": 1.2, "heavy": 1.5, "unknown": 2.0}
return rules.get(weight_class, 2.0)
if __name__ == "__main__":
# Warm up the cache
print(shipping_cost("10001", "90210", 1.2))
print(shipping_cost("10001", "90210", 1.2))
# Inspect cache statistics
info = shipping_cost.cache_info()
print(f"Cache hits: {info.hits}, misses: {info.misses}")
The script prints the cost twice. Because the result is cached, the second call is served from memory, and the cache info will show a hit count of one. You can extend this pattern to more complex calculations, such as cryptographic checksums, dynamic programming tables, or even database query results wrapped in a pure function.
Cache Management
Two methods are typically useful: clearing the entire cache and inspecting its state.
- Clear cache:
shipping_cost.cache_clear()removes all stored entries. Use this when the underlying data changes (e.g., a price update) and you want to guarantee fresh results. - Cache info:
shipping_cost.cache_info()returns a named tuple with hits, misses, maxsize, and currsize. Logging this information helps you tunemaxsizeto balance memory and performance.
When you need to reset the cache periodically, you can schedule a background task or hook into a configuration reload event. The built‑in methods make this straightforward without exposing the internal cache structure.
When to Be Cautious
Never cache functions that rely on mutable global state. The cache assumes the function is deterministic for a given set of arguments; if the result can change because some shared data mutates, you’ll return stale values.
Another common pitfall is caching objects that are large or contain references to file handles, network connections, or other resources. The cache will keep those references alive until eviction, potentially causing memory leaks. For such cases, consider using a smaller maxsize or implementing a custom memoization strategy that releases resources after use.
Finally, be mindful of argument types. lru_cache works best with hashable arguments. If you need to cache a function that receives unhashable types like lists or dicts, convert them to immutable equivalents (tuples, frozen sets) inside the wrapper or use a custom key function.
Wrap‑Up
In a decade of writing Python code I’ve found that the simplest solutions often have the biggest impact. The @lru_cache decorator embodies this principle: a single line of code can turn a repetitive, CPU‑intensive calculation into a near‑instant lookup. By understanding the LRU eviction policy, the thread‑safe guarantees, and the built‑in diagnostic methods, you can confidently apply it to a wide range of problems—from shipping cost calculations to recursive Fibonacci sequences.
Try decorating a hot path in your service with @lru_cache(maxsize=256) and monitor the cache statistics. You’ll likely see a noticeable reduction in latency and database load. The trick is so straightforward that it often gets overlooked, yet it remains one of the most reliable performance boosters in the Python standard library.