Skip to main content

Caching and Partial Application

If you're new to programming

This lesson introduces two tools from Python's functools module. lru_cache is a decorator that remembers function results so the function does not recompute them. partial creates a new function from an existing one with some arguments already filled in. Both save you from writing repetitive code.

If you've coded before

This lesson covers functools.lru_cache (memoization decorator with LRU eviction) and functools.partial (partial function application). If you have used these before, skim the examples and jump to the PRIMM section.

James notices his word frequency function runs slowly when he analyzes the same note twice.

"I already counted those words," he says. "Why is it counting again?"

"Because functions do not remember their previous results," Emma says. "Unless you tell them to. That is what lru_cache does: it keeps a lookup table of inputs and outputs."


functools.lru_cache: Remember Results

Some functions are called with the same arguments repeatedly. lru_cache caches the results so the function runs once per unique input:

from functools import lru_cache


@lru_cache(maxsize=128)
def word_frequency(text: str) -> dict[str, int]:
"""Count word frequency in text. Cached for repeated calls."""
words = text.lower().split()
freq: dict[str, int] = {}
for word in words:
freq[word] = freq.get(word, 0) + 1
return freq

The first call computes the result. Subsequent calls with the same text return the cached result instantly:

# First call: computes frequency
result1 = word_frequency("python is great and python is fun")
print(result1)

# Second call with same input: returns cached result (instant)
result2 = word_frequency("python is great and python is fun")
print(result2)

# Check cache stats
print(word_frequency.cache_info())

Output:

{'python': 2, 'is': 2, 'great': 1, 'and': 1, 'fun': 1}
{'python': 2, 'is': 2, 'great': 1, 'and': 1, 'fun': 1}
CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)

hits=1 means one call used the cache. misses=1 means one call computed the result fresh. The cache stores up to 128 unique results.

Important: lru_cache only works with hashable arguments. An object is hashable if Python can compute a fixed identifier for it (its "hash"). Strings, numbers, and tuples are hashable. Lists and dictionaries are not, because their contents can change. You cannot cache functions that take lists or dictionaries as arguments.


functools.partial: Pre-Fill Arguments

partial creates a new function from an existing one, with some arguments already filled in. It does not call the original function. It creates a version of it that remembers certain arguments:

from functools import partial

def filter_by_tag(notes: list[Note], tag: str) -> list[Note]:
"""Return notes that have the given tag."""
result: list[Note] = []
for note in notes:
if tag in note.tags:
result.append(note)
return result

# Create specialized versions (new functions with tag pre-filled)
python_filter = partial(filter_by_tag, tag="python")
cooking_filter = partial(filter_by_tag, tag="cooking")

# Call them with just the notes argument (tag is already set)
python_notes = python_filter(notes)
cooking_notes = cooking_filter(notes)

print(f"Python notes: {len(python_notes)}")
print(f"Cooking notes: {len(cooking_notes)}")

Output:

Python notes: 1
Cooking notes: 1

partial(filter_by_tag, tag="python") creates a new function that always passes tag="python". You call it with just the notes list.


PRIMM-AI+ Practice: Cache Behavior

Predict [AI-FREE]

Press Shift+Tab to enter Plan Mode.

from functools import lru_cache

@lru_cache(maxsize=128)
def double(n: int) -> int:
print(f"Computing double({n})")
return n * 2

double(3)
double(5)
double(3)
double(7)
double(5)

The function prints a message every time it actually computes. After all five calls, predict:

  1. How many times does "Computing double(...)" get printed?
  2. What does double.cache_info() return for hits and misses?
Check your predictions
Computing double(3)
Computing double(5)
Computing double(7)

Three prints: double(3) computes on the first call, double(5) computes on the first call, double(3) returns cached (no print), double(7) computes, double(5) returns cached (no print).

cache_info() returns CacheInfo(hits=2, misses=3, maxsize=128, currsize=3). Three unique inputs (misses), two repeated inputs (hits), three values stored.

Run

Press Shift+Tab to exit Plan Mode.

Create cache_practice.py and verify your predictions. Run it and compare the printed output to what you expected.

Investigate

Write first: why can you not cache a function that takes a list as an argument? What happens if you try @lru_cache on a function with a list parameter?

If you want to go deeper, run /investigate @cache_practice.py in Claude Code and ask: "What does LRU stand for? What happens when the cache is full and a new unique input arrives? How do I choose the right maxsize?"

Modify

Add @lru_cache to your word_frequency function and measure the speedup by calling it 1000 times with the same input. Print cache_info() to see hits vs misses.

Make [Mastery Gate]

Write and test a cached function reading_time(text: str, wpm: int = 250) -> float that calculates reading time in minutes. In Claude Code, type /tdg to guide you through the cycle:

  1. Write the stub
  2. Write 3+ tests (short text, long text, custom wpm, verify cached result matches uncached)
  3. Prompt AI to implement
  4. Verify with ruff, pyright, pytest

Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Cache Strategy

In Claude Code, type:

/tdg

Use the TDG workflow to write and test a cached function reading_time(text: str, words_per_minute: int = 250) -> float that calculates the reading time in minutes. Add @lru_cache. Write tests that verify the function returns correct values and that caching does not change the results.

What you're learning: Caching is a performance optimization that must not change behavior. Your tests prove that cached and uncached calls produce identical results.

Prompt 2: Partial Application Patterns

Show me three practical uses of functools.partial in a
note-taking application. For each one, show the general
function first, then the specialized partial version.
Explain when partial is clearer than using a lambda
with default arguments.

What you're learning: partial and lambdas with defaults can both pre-fill arguments. The AI helps you see when each approach is more readable. partial documents intent ("this is a specialized version"), while a lambda is more flexible but less self-documenting.

Prompt 3: When Not to Cache

I want to add @lru_cache to several functions in my
SmartNotes app. For each function signature below, tell
me whether caching is safe and why:

1. def word_count(text: str) -> int
2. def search_notes(notes: list[Note], query: str) -> list[Note]
3. def format_note(note: Note, style: str = "plain") -> str
4. def current_timestamp() -> float

What you're learning: Caching is not always appropriate. Functions with mutable arguments (lists), side effects, or time-dependent outputs should not be cached. Recognizing these cases prevents subtle bugs.


James adds @lru_cache to his word frequency function and runs it ten times on the same note. The cache stats show nine hits and one miss.

"Nine lookups from the binder," he says. "One actual computation. At the warehouse, we called that a standing order: same customer, same items, pull from the pre-packed shelf instead of picking from scratch every time."

Emma smiles. "You have comprehensions for building, generators for streaming, key functions for sorting, and now caching for speed. The capstone puts them all together: a complete analytics module for SmartNotes."