Introduction
In the previous lesson, we established that the most significant performance gains come from improving algorithms and choosing the right data structures, such as using a set for membership tests to avoid O(n²) complexity. You learned to use timeit and cProfile to identify these algorithmic bottlenecks and refactor them.
But what happens when the algorithm itself is inherently expensive and cannot be simplified further? Consider tasks like complex mathematical calculations, intensive data processing, or fetching data from a slow external API. Running these operations repeatedly, even with the same inputs, is a waste of computational resources.
This is where caching comes in. In this lesson, we will explore how to trade memory for speed by storing the results of expensive function calls. Our focus will be on memoization, a specific type of caching. You will learn to use caching and memoization (functools.lru_cache) to avoid re-computing expensive, pure function calls. This is a powerful, Pythonic technique for optimizing code without altering its fundamental logic.
Caching and Memoization: The Core Idea
Before we dive into the code, let's clarify the concepts.
Caching is a general optimization technique where you store the results of an operation in a faster-to-access location (the "cache"). A web browser caching a webpage is a classic example; the next time you visit, the page loads from your local disk instead of being re-downloaded.
Memoization is a specific form of caching applied to functions. When you memoize a function, you store the results of its calls. If you call the function again with the same arguments, you get the stored result instantly instead of re-computing it.
To get a clear overview of these concepts, watch the first part of the following video.
Using caching and memoization to optimize Python performance
This video from Sebastiaan Mathôt provides a concise explanation of caching and memoization, using familiar examples to build a strong conceptual foundation.
Watch the video from the beginning until 02:26. Focus on the distinction between the general concept of caching (like a web browser) and the specific programming technique of memoization (caching function calls).
When to Use Memoization: The Three Critical Rules
Memoization is not a magic bullet; applying it to the wrong function can have no effect or, worse, introduce bugs. A function is a good candidate for memoization if and only if it meets these three criteria:
-
Pure / Deterministic: The function must be a pure function. This means for the same set of inputs, it always returns the same output and has no side effects (like modifying a global variable, writing to a file, or calling
print).- Good candidate:
factorial(n) - Bad candidate:
get_current_time()or a function that returns a random number.
- Good candidate:
-
Expensive: The function's computation must be costly enough that the overhead of checking the cache and storing the result is negligible in comparison. Memoizing a function that just adds two numbers is pointless.
- Good candidate: A recursive Fibonacci function, a complex regex compilation, or a call to a slow network service.
- Bad candidate:
is_even(n)
-
Called Repeatedly with the Same Arguments: The function must be likely to be called multiple times with the same inputs. There's no benefit in caching a result that you'll never need again.
This next segment of the video you just watched explains these three properties perfectly.
Using caching and memoization to optimize Python performance
Now, let's explore the three properties that make a function suitable for memoization: determinism, computational expense, and repeated calls.
Watch from 06:55 to 09:27. The video uses a decompose function as its example to illustrate these three crucial points.
Implementing Memoization with functools.lru_cache
While you could implement memoization manually using a dictionary, this approach is error-prone (especially with the mutable default argument bug). Python's "batteries included" philosophy provides a robust, production-ready solution: the @lru_cache decorator.
LRU stands for "Least Recently Used". It's a caching strategy that discards the least recently used items first when the cache is full.
Let's see it in action with the classic example: a recursive Fibonacci function. This function is a perfect candidate for memoization because it is pure, expensive (O(2ⁿ)), and involves many overlapping subproblems (repeated calls).
# fibonacci_slow.py
import timeit
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# This will take a noticeable amount of time
print("Calculating fibonacci(35)...")
time_taken = timeit.timeit("fibonacci(35)", globals=globals(), number=1)
print(f"Result: {fibonacci(35)}")
print(f"Time taken: {time_taken:.4f} seconds")
Now, let's apply @lru_cache.
# fibonacci_fast.py
import timeit
from functools import lru_cache
@lru_cache(maxsize=None) # maxsize=None means the cache can grow without bound
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print("Calculating fibonacci(35) with cache...")
time_taken = timeit.timeit("fibonacci(35)", globals=globals(), number=1)
print(f"Result: {fibonacci(35)}")
print(f"Time taken: {time_taken:.4f} seconds")
Running these two scripts demonstrates a staggering performance improvement, from several seconds to microseconds. The decorator transparently intercepts calls to fibonacci, stores the result for each n, and on subsequent calls for the same n, returns the stored value instead of descending into recursion.
This next video uses the same Fibonacci example to showcase the "massive power" of lru_cache.
How “lru_cache” Can Make Your Functions Over 100X FASTER In Python
This video from Indently provides another excellent demonstration using the Fibonacci sequence, showing how lru_cache makes a previously un-runnable calculation (e.g., fibonacci(200)) instantaneous.
Watch from 07:19 to 10:55. Pay attention to how the execution time for large numbers goes from 'far too long' to practically instant.

Fine-Tuning and Inspecting the Cache
@lru_cache is more than just a simple on/off switch. You can control its behavior and inspect its state, which is vital for production systems.
maxsize: This argument controls the cache size. The default is128. If the cache exceeds this size, the least recently used item is discarded. Setmaxsize=Nonefor an unlimited cache, but be mindful of memory consumption. In Python 3.9+, you can use the simpler@functools.cache, which is equivalent to@lru_cache(maxsize=None).typed: If set toTrue, arguments of different types will be cached separately. For example,f(3)andf(3.0)would be treated as two distinct calls. The default isFalse..cache_info(): A method on the decorated function that returns a named tuple with cache statistics:hits,misses,maxsize, andcurrsize. This is invaluable for understanding if your cache is effective..cache_clear(): Clears the cache entirely. This is useful in tests or when you know the underlying data has changed.
This video segment will walk you through these practical features.
How “lru_cache” Can Make Your Functions Over 100X FASTER In Python
Let's learn how to manage and inspect the cache using its built-in tools.
Watch from 01:51 to 06:29. Focus on how maxsize works, and how to use .cache_info() to see hits/misses and .cache_clear() to reset the cache.
Common Pitfalls and Best Practices
To use memoization safely and effectively, you must be aware of its limitations. Given your goal of writing robust, production-ready code, these points are critical.
How to apply memoization in Python using functools
The AlgoCademy article 'How to apply memoization in Python using functools' has an excellent section on common pitfalls. We will review it carefully.
Read the section titled 'Common Pitfalls and How to Avoid Them'. Pay close attention to the four points: Mutable Arguments: Why you can't use lists or dicts as arguments. Functions with Side Effects: How caching breaks functions that aren't pure. Memory Usage: The danger of an unbounded cache with a large input space. Instance Methods: The subtlety of self being part of the cache key.
Let's summarize the most crucial pitfall: function arguments must be hashable.
The @lru_cache decorator uses the function's arguments to create a key for its internal dictionary cache. Dictionary keys must be hashable. This means you cannot pass mutable objects like lists, dictionaries, or sets directly to a cached function.
from functools import lru_cache
@lru_cache(maxsize=None)
def process_data(data_list): # This will fail!
print("Processing...")
return sum(data_list)
# ❌ This will raise a TypeError: unhashable type: 'list'
# process_data([1, 2, 3])
# ✅ The solution is to convert mutable arguments to their immutable counterparts.
@lru_cache(maxsize=None)
def process_data_fixed(data_tuple):
print("Processing...")
return sum(data_tuple)
# This works because tuples are hashable.
process_data_fixed(tuple([1, 2, 3])) # First call, prints "Processing..."
process_data_fixed(tuple([1, 2, 3])) # Second call, returns from cache instantly.
Exercise: Apply and Measure Memoization
You are given a function that simulates a slow, external API call to fetch weather data for a city. The function has an artificial delay to represent network latency. Your script repeatedly asks for forecasts for a few cities.
Your task is to optimize this script using memoization.
Starter Script:
# weather_optimizer.py
import time
import timeit
def fetch_weather_data(city: str) -> dict:
"""
Simulates a slow API call to fetch weather data.
This function is PURE and EXPENSIVE.
"""
print(f"--- Fetching weather data for {city} from the API... ---")
# Artificial delay to simulate network latency
time.sleep(1)
if city == "New York":
return {"city": "New York", "temperature": "15°C", "condition": "Cloudy"}
elif city == "London":
return {"city": "London", "temperature": "12°C", "condition": "Rainy"}
elif city == "Tokyo":
return {"city": "Tokyo", "temperature": "22°C", "condition": "Sunny"}
else:
return {"city": city, "error": "City not found"}
def run_forecasts():
"""Simulates a user requesting forecasts for a list of cities."""
cities = ["New York", "London", "Tokyo", "New York", "London", "London"]
for city in cities:
data = fetch_weather_data(city)
print(f"Forecast for {data['city']}: {data.get('condition', 'N/A')}")
if __name__ == "__main__":
# Measure the unoptimized version
time_taken = timeit.timeit("run_forecasts()", globals=globals(), number=1)
print(f"\nTotal time taken (uncached): {time_taken:.4f} seconds")
# Your task is to create and measure an optimized version below.
Your Tasks:
- Copy the code into a new Python file.
- Apply
@lru_cache: Importlru_cachefromfunctoolsand apply it as a decorator to thefetch_weather_datafunction. Since we are dealing with a small number of cities, an unbounded cache (maxsize=None) is fine. - Run and Observe: Run the script. Notice that even though
run_forecastscallsfetch_weather_datasix times, the "Fetching..." message only appears three times. The subsequent calls for "New York" and "London" are served from the cache. The total time taken should drop from ~6 seconds to ~3 seconds. - Inspect the Cache (Bonus): After the
run_forecastscall, add a line to print the cache info:print(fetch_weather_data.cache_info()). Analyze the output. You should seehits=3,misses=3, andcurrsize=3.
Conclusion
You have now added memoization, a powerful optimization technique, to your toolkit. It is the perfect solution when you identify a performance bottleneck in a function that is pure, expensive, and frequently called with the same arguments.
Key Takeaways:
- Memoization is a form of caching that stores function call results to avoid re-computation, trading memory for speed.
functools.lru_cacheis the idiomatic Python tool for memoization.- Only memoize functions that are pure (deterministic), computationally expensive, and called repeatedly with the same inputs.
- Be aware of the pitfalls: function arguments must be hashable, and memoizing functions with side effects leads to bugs.
- Use
.cache_info()to inspect and validate the effectiveness of your cache.
What's Next?
We've focused on optimizing CPU-bound tasks, either by improving algorithms or by caching their results. However, performance is not just about CPU speed; it's also about memory. In our next lesson, we will shift our focus to memory considerations. You will learn how to write memory-efficient code by replacing in-memory lists with generator-based streaming pipelines, a crucial skill for processing large datasets without running out of memory.