skip to content

lru_cache and cached_property

Memoization you get for free: @lru_cache and @cache key on the argument tuple, so unhashable arguments fail and every result stays alive. @cached_property instead overwrites itself on the instance.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

What do the maxsize and typed arguments to functools.lru_cache control?

level: juniorimportance: must knowfreq 50%

answer

  1. Two knobs, one lookup table
  2. One bounds it, one keys it
  3. Full table drops the coldest entry
  4. Bare decoration still holds 128
  5. 1, 1.0 and True can collide

basics

~20 s

maxsize caps how many results are stored - 128 by default, None for unbounded, 0 for none - and a full table drops the least recently used entry. typed=True adds argument types to the key, so 1 and 1.0 stop sharing.

solid answer

~50 s

`functools.lru_cache` turns each call into a key and returns the stored result on a hit; its two arguments control the two halves of that. **`maxsize`** counts entries, not bytes: it defaults to 128, `maxsize=None` removes eviction so the table grows without bound (that is what `functools.cache` is), and `maxsize=0` disables caching so every call runs. When a full table takes a new key, the *least recently used* entry is discarded - the one longest without a hit, not necessarily the oldest inserted, because every hit refreshes an entry. **`typed`** decides whether the key remembers types. With the default `typed=False` the key holds values, so arguments that compare equal and hash alike - `1`, `1.0`, `True` - can collapse onto one entry and the caller gets whichever result was computed first. `typed=True` appends each argument's type, making those three entries.

code

python · 18 lines
python
from functools import lru_cache


@lru_cache(maxsize=2)
def square(n):
    print("computing", n)
    return n * n


square(1)
square(2)
square(1)   # a hit: refreshes 1, so 2 is now the coldest entry
square(3)   # table is full -> 2 is evicted, 1 survives
print(square.cache_info())

square(1)   # still a hit
square(2)   # recomputed
print(square.cache_info())

go deeper

for a junior

Be ready to say what each argument is for without hesitating: maxsize is a count of stored results, defaulting to 128, with None meaning no limit; typed=False is the default and keys the cache by value alone. Knowing that a bare decoration is still bounded matters.

for a middle

Explain the mechanics behind both. Walk through why eviction is least-recently-used rather than first-in-first-out, describe how the key is assembled from positional and keyword arguments, and show with a concrete pair such as 1 and 1.0 what typed=True changes about the number of entries.

for a senior

Demonstrate that you read typed as a correctness setting: name the shapes - branching on type, formatting numbers, handing the value to something type-sensitive - where a shared entry returns the wrong flavour of answer. Expect to mention that neither argument can be changed after decoration and that concurrent misses can both run the body.

for a principal

Own the convention rather than the call site. An interviewer at this level wants a defensible house rule on where memoization is allowed to live, whether unbounded caches are ever acceptable in a long-lived process, and how a review catches a wrapper whose default typed setting quietly makes results depend on which caller arrived first.

`functools.lru_cache` wraps a function in a memoizing wrapper: every call is converted into a cache key, the key is looked up in a dictionary, and on a hit the stored result is returned without running the body. The decorator takes exactly two arguments, and they govern the two halves of that mechanism - how large the table is allowed to grow, and how the key is built. ## maxsize - how many results are held, and which one leaves `maxsize` counts **entries**, not bytes and not megabytes. It defaults to 128, and that default applies to the parenthesis-free `@lru_cache` form too - a bare decoration is bounded at 128, not unbounded. Once the table holds `maxsize` distinct keys, storing a new key evicts one existing entry, and the policy is the decorator's name: **least recently used**. CPython keeps entries in a circular doubly linked list; every *hit* moves that entry to the most-recent end. So a key that is read constantly survives indefinitely, while a key inserted after it can be thrown out. This is the point candidates most often get wrong - eviction is not FIFO, and the oldest *inserted* entry is not necessarily the one that goes. Two values are special. `maxsize=None` switches eviction off: the table grows for the life of the process, and `functools.cache` is defined as exactly that shorthand. `maxsize=0` switches *caching* off: nothing is ever stored, every call runs the body, and only the miss counter moves - useful as a configuration-driven off switch that keeps the wrapper's shape intact. Both arguments are fixed at decoration time. You cannot resize a live cache; the wrapper reports what it was built with through `cache_parameters()`, and changing either one means building a new wrapper (and losing whatever the old one held). For inspection, the wrapper exposes `cache_info()`, which reports hits, misses, the configured maxsize and the current entry count, and `cache_clear()`, which empties the table and resets the counters. ## typed - whether the key remembers argument types The key is built from the call's positional arguments followed by its keyword arguments, with a marker separating the two. Two consequences follow immediately. First, the *same* value passed positionally and by keyword produces two different keys: `f(1)` and `f(x=1)` are two entries and two executions, even though they are the same call to a reader. Second, because the key stores argument **values**, lookup goes through the normal dictionary rules of `__hash__` and `__eq__`. That second point is what `typed` exists for. `1`, `1.0` and `True` all hash to the same value and compare equal, so with the default `typed=False` calls that differ only in those types can land on a single entry - and the caller receives the result computed for whichever type arrived first. With `typed=True`, each argument's type is folded into the key, so `f(1, 0)`, `f(1.0, 0)` and `f(True, 0)` are three separate entries and the body runs three times. This is a **correctness** setting, not only a hit-rate one. If the function branches on `type()` or `isinstance`, formats a number differently for an integer than for a float, or hands the argument to something type-sensitive downstream, `typed=False` will happily return a result of the wrong flavour. When the function's output genuinely does not depend on the argument's type, leaving `typed=False` keeps the hit rate higher, since `typed=True` multiplies the number of distinct keys under the same `maxsize`. One honest caveat that the documentation states and interviewers appreciate hearing: with `typed=False` equal-comparing arguments are *usually* treated as one call, not always. CPython has a fast path for a single positional argument that is exactly an `int` or a `str`, which uses the argument itself as the key - so in that one shape `f(1)` and `f(1.0)` can end up in separate entries, while `f(1.0)` and `f(True)` still share one. The reliable reading is directional: with `typed=False` equal arguments *may* collapse; with `typed=True` they never do. ## Putting the two together Read the pair as "how much do I keep" and "what counts as the same call". `maxsize` is the safety valve on a wrapper that otherwise holds every argument and every result it has ever seen, which in a long-lived process is exactly a growing table. `typed` decides whether the cache is allowed to answer an `int` question with a `float` answer. Finally, note what neither controls: the wrapper protects its own table, but two threads that miss on the same key at the same time can both run the body, so `lru_cache` is a memo, not a mutual-exclusion mechanism.

  • Does functools.lru_cache treat f(1) and f(x=1) as the same cache entry?
    No. The key is the positional arguments followed by the keyword arguments, with a marker between them, so passing the same value positionally and by keyword produces two different keys. Both calls miss, the body runs twice, and the table holds two entries for what a reader sees as one call. If callers mix the two styles, a positional-only or keyword-only signature keeps the cache from splitting.
  • What does maxsize=0 do to an lru_cache-wrapped function, and why would anyone use it?
    It disables storage entirely: every call runs the body, `currsize` stays at zero, and only the miss counter climbs. It is useful as a configuration-driven off switch - the wrapper, its `cache_info()` and `cache_clear()` methods and the call signature all stay in place, so caching can be turned off in one environment without changing any calling code, and the miss count still tells you how often the function was reached.
  • Can you change maxsize or typed on a cache that is already in use?
    No. Both are captured when the decorator is applied; `cache_parameters()` reports them back but there is no setter. Changing either means constructing a new wrapper around the function, which starts with an empty table - the existing entries are not carried over. If a limit needs to vary by environment, pass it in at decoration time from configuration rather than trying to adjust the wrapper later.

A coat check with a fixed number of hooks: when every hook is taken and a new coat arrives, the attendant clears out the coat nobody has come back for in the longest time - not necessarily the one hung up first.

saying these in an interview costs you the question

  • Says maxsize is a memory limit in bytes, not an entry count
  • Thinks eviction is FIFO - the oldest inserted entry goes
  • Confuses maxsize=0 with maxsize=None and expects an unbounded cache
  • Believes a bare @lru_cache is unbounded rather than 128 entries
  • Assumes typed=True is the default, so types never collapse
  • Treats typed as a speed knob only, never a correctness one

context

open as a page

Why does an @functools.lru_cache-decorated function raise TypeError when passed a list?

level: middleimportance: must knowfreq 52%

basics

~20 s

The cache key is built from the call's arguments and used in a dictionary, so every argument must be hashable. A list is unhashable, so building the key raises TypeError before the wrapped function ever runs. Pass a tuple or a frozenset instead.

open as a page

How does @functools.cached_property differ from @property on repeated attribute access?

level: middleimportance: should knowfreq 46%

basics

~20 s

property runs its function on every attribute read. functools.cached_property runs it on the first read only, stores the result in that instance's dict under the same name, and every later read finds that ordinary instance attribute, so the function is never called again.

open as a page

Why does an @functools.lru_cache-wrapped formatter in an invoice-PDF renderer keep returning the previous currency format after a global setting changes, and how do you fix it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The cache key is built from the arguments only. The currency setting is ambient state the key never sees, so a call with the same amount hits the entry stored before the change. Fix it by making the setting an explicit parameter, or clear the cache when it changes.

open as a page