Referential transparency: the property that makes memoization actually safe
Caching Strategies' own domain covered memoization-adjacent ideas without naming the specific property that makes caching a function's return value SAFE to do at all. That property has a name, and it's a direct, formal consequence of purity (lesson 1) — this lesson connects the two.
Intermediate
3 min read
Referential transparency, defined precisely
A function call is referentially transparent if it can be replaced by its actual return value, anywhere in the program, without changing the program's behavior at all. add(2, 3) can always be replaced by the literal 5 with zero observable difference — add is referentially transparent. A call like get_current_user_balance(user_id) cannot be safely replaced by whatever value it returned last time, because the balance can change between calls — it's not referentially transparent, even if the function itself has no bugs.
def square(x): return x * xsquare(4) # can be replaced by 16 anywhere, forever — referentially transparentdef get_random(): return random.random()get_random() # CANNOT be replaced by a fixed value — not referentially transparent
Why this is the exact property memoization depends on
from functools import lru_cache@lru_cache(maxsize=None)def expensive_computation(n): # ... some genuinely slow, pure calculation return result
Memoization caches a function's return value, keyed by its arguments, so a repeated call with the same arguments returns the cached result instantly instead of recomputing — this is only correct to do at all if the function is referentially transparent. Caching Strategies' own domain covered negative caching, stampedes, and eviction policies for caching data fetched from a database or network — memoization is the same underlying idea (trade memory for avoided recomputation) applied specifically to a pure function's own output, and it's safe in a way caching an impure function's result never is: a referentially transparent function's result genuinely never goes stale, because by definition it can never return something different for the same input.
What happens when you memoize an impure function by mistake
@lru_cache(maxsize=None)def get_user_balance(user_id): return db.query_balance(user_id) # the ACTUAL balance can change over time!get_user_balance(42) # returns $100, and CACHES it forever# ... user deposits money, balance is now $150 in the database ...get_user_balance(42) # STILL returns the cached $100 — silently wrong
Memoizing a function whose result genuinely depends on mutable external state (a database row that can change) doesn't just fail to help — it introduces a real, silent correctness bug, serving a stale result forever with no TTL or invalidation mechanism at all, since the memoization cache has no way to know the underlying data changed (exactly the cache-invalidation problem Caching Strategies' own domain covered at length, reappearing here in its simplest, most dangerous form: a cache with no invalidation strategy whatsoever).
Why this matters for deciding what to memoize in real code
Before reaching for @lru_cache or any equivalent memoization decorator, the actual question is whether the function being wrapped is genuinely referentially transparent — a pure mathematical calculation, a deterministic parsing function, anything whose result depends on nothing but its own arguments. A function wrapping a database query, a file read, or any value that can change independently of the function's own arguments is the wrong target for memoization specifically, and needs one of Caching Strategies' own, more deliberate caching patterns (with a real TTL or explicit invalidation) instead.