← Python Code Loops, Functions & Scopes
Browse Python Concepts

functools.partial and lru_cache

Mental Model

Think of lru_cache as a filing cabinet bolted to a specific function object, not to the logic inside it. Calling lru_cache(...) inside a factory bolts a brand-new, empty cabinet to a brand-new object every time the factory runs. All the drawers you opened on the previous cabinet mean nothing to the new one. Decorating at the module level means every caller shares the same cabinet.

Rule: Always call cached functions with a consistent positional or keyword signature, and avoid dynamic partial bindings on cached interfaces in transient request lifecycles.

The Setup

You want to optimize API response processing by caching database lookups using lru_cache. To avoid passing credentials everywhere, you use functools.partial to bind connection strings dynamically.

What Does This Print?

Broken code
Python
from functools import lru_cache, partial

def fetch_user_data(connection_string, user_id):
    print(f"[DB Query] Executing query for user {user_id}")
    return {"id": user_id, "source": connection_string}

def get_user_service(connection_string):
    # Apply lru_cache here to create a per-connection cached accessor
    return lru_cache(maxsize=128)(partial(fetch_user_data, connection_string))

conn = "postgresql://prod_replica"
service_a = get_user_service(conn)
service_b = get_user_service(conn)

service_a(42)
service_b(42)
Predict what is printed. Does the second call hit the cache, or does it trigger a second database query?

The Output

What actually happens
[DB Query] Executing query for user 42 [DB Query] Executing query for user 42

Both calls print. Despite identical arguments and the same connection string, the cache is never shared between service_a and service_b. Each call to get_user_service invokes lru_cache(maxsize=128)(...), which constructs a brand-new cache wrapper attached to a brand-new partial object. service_a and service_b are entirely independent cached functions with separate, empty caches — so every call to either is a guaranteed miss.

Why Python Does This

lru_cache stores its cache dictionary as state on the wrapper object it creates, not on the underlying function. Every time lru_cache(maxsize=128)(partial(...)) executes, Python allocates a new wrapper object with a new empty cache. Because get_user_service creates both the partial and the lru_cache wrapper on each call, service_a and service_b are two distinct objects with two isolated caches. Neither knows what the other has fetched.

The Fix

Corrected pattern
Python
from functools import lru_cache, partial

# Apply lru_cache once at definition time — the cache lives on this object
@lru_cache(maxsize=128)
def fetch_user_data(connection_string, user_id):
    print(f"[DB Query] Executing query for user {user_id}")
    return {"id": user_id, "source": connection_string}

def get_user_service(connection_string):
    # partial only pre-fills arguments; the cache on fetch_user_data is shared
    return partial(fetch_user_data, connection_string)

conn = "postgresql://prod_replica"
service_a = get_user_service(conn)
service_b = get_user_service(conn)

service_a(42)  # [DB Query] - cache miss
service_b(42)  # cache hit — no print

Decorating fetch_user_data at definition time attaches one cache dictionary to that single function object for the module's lifetime. Every partial created from it and every direct call to it funnel through the same cache lookup. The partial object only pre-fills positional arguments before the call reaches the shared cache — it does not create a new cache.

How This Fails in Real Systems

A high-load web application used lru_cache to cache configuration values retrieved via dynamic partial adapters. Because adapters were instantiated on every incoming request, cache keys never matched, leading to 100% cache misses, a massive connection pool exhaustion, and a 15-minute outage under moderate traffic.

Key Takeaway

Always call cached functions with a consistent positional or keyword signature, and avoid dynamic partial bindings on cached interfaces in transient request lifecycles.
Common mistake: Developers apply lru_cache inside a factory or constructor to create per-instance caches, not realising each call creates an entirely new cache object, resulting in a 100% miss rate regardless of repeated identical inputs.