Why does functools.lru_cache raise TypeError when the function is called with a list?
answer
- The arguments become a dictionary key
- Mutable containers cannot be keys
- Positional and keyword forms key differently
- typed decides whether 1 and 1.0 differ
- Freeze to a tuple or frozenset
basics
~20 sThe wrapper builds a dictionary key out of the call's arguments, so every argument must be hashable. A list is not, so the lookup raises TypeError before the function body ever runs. Pass a tuple, or a frozenset for set-like input.
solid answer
~40 sThe memoized wrapper turns the call's arguments into a dictionary key, so each argument must be hashable and comparable with `==`. A `list`, `dict` or `set` argument therefore fails with `TypeError: unhashable type` at lookup time, before the body runs. Two consequences follow that surprise people. First, the key distinguishes how an argument was passed: `f(1)` and `f(x=1)` produce different entries and each pays a miss. Second, with the default `typed=False` the key is compared by equality, so `1`, `1.0` and `True` collapse into one entry, because they hash and compare equal; `typed=True` keeps them apart at the cost of more entries. The usual fix for a mutable argument is a thin uncached wrapper that freezes the value - `tuple(...)` or `frozenset(...)` - and calls the cached inner function.
code
python · 12 linesimport functools
@functools.lru_cache
def total(values):
print("computing")
return sum(values)
print(total((1, 2, 3)))
try:
total([1, 2, 3])
except TypeError as exc:
print("TypeError:", exc) # unhashable type: 'list', and 'computing' never printedgo deeper
Recall that cached arguments must be hashable, and that a list, dict or set argument raises TypeError. Knowing to pass a tuple instead already answers the screening version of this question.
Explain that the arguments are turned into a dictionary key, and be ready to show the freeze-at-the-boundary pattern plus the surprise that positional and keyword calls key separately.
Discuss what to key on rather than how to make a key legal: small stable identities over payloads, the cost of freezing on every call, and the correctness risk when a domain object's equality decides hits.
Frame it as an interface question: which functions in the codebase are shaped to be memoizable at all, and whether a convention such as positional-only parameters is worth enforcing so caches do not split.
## The key, not the argument A memoized wrapper is a dictionary lookup wearing a function's clothes. Before it can decide hit or miss, it has to reduce the call to a key, and that key is built from the positional arguments, a separator, and the keyword arguments as name/value pairs. Because the result is used as a `dict` key, every value that goes into it must be **hashable**: it must have a stable hash and compare with `==`. Python's built-in mutable containers - `list`, `dict`, `set`, `bytearray` - are deliberately unhashable, so passing one raises `TypeError: unhashable type: 'list'` at the lookup, before the wrapped function's first statement runs. That is a useful detail when reading a traceback: the error is raised by the caching layer, not by your code. ## Why the key is stricter than you expect Two behaviours follow from key construction rather than from anything in your function. **Calling convention is part of the key.** `f(1)` and `f(x=1)` are the same call to your function and two different keys to the cache, so the second form pays a miss and stores a duplicate entry. In a codebase where some callers use keywords for readability and others do not, a cache can quietly hold two entries for every logical input and halve its hit ratio. **Equality decides identity of keys.** With the default `typed=False`, keys are compared the way `dict` compares any keys: by hash then `==`. `1`, `1.0` and `True` hash the same and compare equal, so a call with any of them hits the entry stored by the first. If the function's result depends on the argument's *type* - formatting, serialization, dispatch on numeric kind - that is a genuine wrong answer, and `typed=True` is the switch that makes the key type-sensitive. The price is more entries, because `1` and `1.0` now occupy separate slots. A third, subtler case: keys are your objects, so *your* `__eq__` and `__hash__` decide hits. A class whose equality is defined loosely - comparing only an id field while other fields differ - will hand out one instance's cached result to another instance that your business logic considers different. Memoizing a function whose arguments are rich domain objects makes the cache correctness depend on an equality definition written for some other purpose. ## The freeze-at-the-boundary pattern When a function must accept a mutable container, the conventional shape is two functions: a public, uncached one that converts the argument into an immutable equivalent, and a private, cached one that takes the frozen form. `tuple(values)` for an ordered sequence, `frozenset(values)` when order and duplicates are irrelevant, and a sorted tuple of items when a mapping must be keyed. This is not free, and the costs deserve saying out loud in an interview. The conversion runs on every call, including hits, so for a large sequence the freezing can cost more than the memoized computation saved. The frozen value must itself contain only hashable elements - `tuple([[1], [2]])` is still unhashable, because hashing a tuple hashes its members. And freezing changes the key's meaning: `frozenset` discards order and duplicates, which is correct only if the function genuinely ignores them. ## Practical consequences Three habits fall out of all this. Key on small, stable identities rather than payloads: an identifier or a short tuple of parameters, not the object being processed. Standardize the calling convention for a memoized function, or make its parameters positional-only so callers cannot split the cache in two. And be explicit about `typed` when the function's behaviour differs by numeric type, rather than discovering it through a bug where an integer-valued float returned the integer's formatting. Finally, remember what the key retains. The dictionary holds a strong reference to every key and every value, so an argument used as a key stays alive for as long as its entry does. Keying on a large object does not merely risk a slow hash - it pins that object in memory until the entry is evicted or the cache is cleared.
- How do you memoize a function whose public signature must accept a list?Split it in two: keep the public function uncached, have it convert the list into a tuple (or a frozenset when order and duplicates do not matter), and put the cache on a private function taking the frozen form. Two caveats: the conversion runs on every call including hits, so it must be cheaper than the work saved, and the frozen value must contain only hashable elements, since hashing a tuple hashes its members.
- How can functools.lru_cache return a result for an object your code considers different?Keys are matched by hash and equality, so the class's own __hash__ and __eq__ decide what counts as the same call. A type whose equality compares only some fields will match two instances your logic treats as distinct, and the second call receives the first one's result. Memoizing over rich domain objects therefore makes cache correctness depend on an equality definition usually written for some other purpose.
- When is typed=True worth the extra entries?When the function's result depends on the argument's type and not only its value: formatting, serialization, or anything dispatching on numeric kind. With the default typed=False, 1, 1.0 and True share a single entry because they hash and compare equal, so a call with a float can receive the integer's result. typed=True keys them separately, at the cost of storing several entries for values that are numerically equal.
The cache files answers in a cabinet by a label written from the arguments. A list is a label you can rewrite after filing, so the cabinet refuses to accept it at all.
saying these in an interview costs you the question
- Says any object at all can be a cache key
- Believes the wrapper copies mutable arguments to key them
- Assumes f(1) and f(x=1) share one entry
- Converts a list to a string to make it hashable
- Thinks typed=True is the default