Why does functools.lru_cache raise TypeError when a list is passed as an argument?
answer
- The key lands in a dictionary
- Mutable containers define no hash
- Convert at the boundary, cache inside
- Positional and keyword calls key differently
- typed controls whether 1 and 1.0 share
basics
~20 sThe wrapper builds a key from the call's arguments and looks it up in a dictionary, so every argument must be hashable. A list is not, and the lookup raises TypeError: unhashable type: 'list' before the function body ever runs.
solid answer
~50 s`functools.lru_cache` stores results in a dictionary keyed by the call's arguments, so the key — and therefore every argument in it — must be hashable. Passing a `list`, `dict` or `set` raises `TypeError: unhashable type` from inside the wrapper, before the body runs. The usual fixes are to take a `tuple` or `frozenset` instead of a mutable collection, or to keep an uncached public function that normalises its arguments and delegates to a cached inner one. Two further consequences of the key being built from the raw call: positional and keyword forms of the same call are *different* keys, so `f(3)` and `f(x=3)` occupy two entries, as do keyword arguments supplied in different orders. And with the default `typed=False`, arguments that compare equal and hash equal — such as `1` and `1.0` in a two-argument call — share one entry; `typed=True` keeps them apart.
code
pycon · 11 lines>>> from functools import lru_cache
>>> @lru_cache
... def total(values):
... return sum(values)
...
>>> total([1, 2, 3])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> total((1, 2, 3))
6go deeper
Remember that cache keys are dictionary keys, so arguments must be hashable, and that lists, dicts and sets are not. Recognising the unhashable type error message and reaching for a tuple is the expectation here.
Explain that the key is built from the call's arguments and hashed, so the error is raised by the wrapper before the body runs. Offer both fixes — change the parameter type, or normalise in an uncached outer function — and note the conversion cost.
Show awareness of the subtler key behaviour: positional versus keyword form and keyword ordering create separate entries and quietly halve hit rates, and the default typed=False merges equal-but-differently-typed arguments. Also flag mutable return values as the mirror-image bug.
Treat hashability as a signature-design question: functions you intend to memoize should take immutable arguments and return immutable results, and that constraint belongs in the API contract rather than being patched with a wrapper at each call site.
## Why hashability is not negotiable A memoization cache has to answer "have I seen this call before?" in less time than recomputing the result. The only data structure in Python that answers that in constant time is the dictionary, and dictionary keys must be hashable. So the requirement is not a design choice the decorator made; it falls straight out of the mechanism. The wrapper builds one key object from the whole call — the positional arguments, a marker, then the keyword arguments as name/value pairs — and hashes it. Hashing a tuple hashes each element, so one unhashable argument anywhere in the call poisons the whole key. Mutable built-in containers (`list`, `dict`, `set`, `bytearray`) define no `__hash__`, and a class that defines `__eq__` without `__hash__` also becomes unhashable. The failure surfaces as `TypeError: unhashable type: 'list'` raised from the wrapper, so the traceback points at the decorated call site and not at anything inside the function body — which is a useful tell when you are reading someone else's stack trace. ## Fixing it The honest fix is usually to change the parameter type. If the function conceptually takes a sequence of values, take a `tuple`; if it takes a set of flags, take a `frozenset`; if it takes options, take individual keyword parameters rather than an options `dict`. Callers converting at the boundary is cheap and makes the immutability contract explicit. When you cannot change the public signature, split the function: an uncached outer function normalises the arguments into hashable form, and a cached inner function does the work. ```python import functools @functools.lru_cache(maxsize=512) def _score(values): # values is a tuple return sum(values) / len(values) def score(values): # public API still accepts a list return _score(tuple(values)) ``` The cost is real and worth stating: the normalisation runs on every call, hit or miss, so the conversion must be much cheaper than the work being cached. Converting a million-element list to a tuple on every call to save a cheap computation is a net loss. A third option people reach for — wrapping the mutable argument in a class with a custom `__hash__` — is a trap unless the object really is immutable in practice. If the object mutates after being used as a key, the cache will return an answer computed for a state the object no longer has, which is far worse than the `TypeError` you were avoiding. ## Two surprises in how the key is built **Calling convention is part of the key.** The key is built from how the call was *written*, not from the bound parameters. `scale(3)` and `scale(3, factor=2)` are two entries even when the default makes them identical, and `k(a=1, b=2)` and `k(b=2, a=1)` are two entries because keyword arguments enter the key in the order they were passed. A caller that varies its style therefore halves the hit rate for free. If a hot path is inconsistent about this, normalise at the boundary the same way you would for hashability. **typed=False collapses equal-but-differently-typed values.** By default the key compares by equality, so in a two-argument call `g(1, 2)` and `g(1.0, 2.0)` share one entry — the second is a hit and gets an `int`-derived result. That is usually what you want; when the function's output actually differs by type, pass `typed=True` and the arguments' types become part of the key. ## The related trap: mutable *results* Hashability constrains what goes in. Nothing constrains what comes out, and the cache stores the returned object itself. If the cached function returns a list, the first caller and every later hit share one list; an `append` by any of them is visible to all of them, and the corruption looks like a cache that is "returning wrong answers". Return immutable results from cached functions, and if the caller genuinely needs a mutable copy, let the caller copy it. ## What this tells an interviewer The `TypeError` itself is a five-second answer. The interesting half is what you do next: recognising that the requirement is structural rather than arbitrary, choosing between changing the signature and splitting the function, weighing the normalisation cost against the cached work, and knowing that the same key machinery quietly rewards callers who are consistent about positional versus keyword style.
- How would you cache a function whose public signature must accept a list?Keep the public function uncached and have it normalise — `tuple(values)` — before delegating to a cached private function that takes the tuple. The conversion then runs on every call, so it only pays off if it is far cheaper than the work being cached. Changing the public parameter to a tuple outright is better when you own the callers, because it makes the immutability requirement part of the contract.
- When is typed=True worth setting?When arguments that compare equal should produce different results because of their type — for example a formatter that renders an `int` and a `float` differently. With the default `typed=False` those calls share one entry and the second caller silently receives the first one's answer. `typed=True` puts the argument types into the key, at the cost of more entries for the same workload.
- A cached function returns a dict and callers report it changing under them. What happened?The cache stores the returned object, not a copy, so every hit hands back the same dict; one caller mutating it corrupts the value for everyone. Return an immutable structure — a tuple of pairs, a frozen dataclass, or `types.MappingProxyType` over an internal dict — or drop the cache. Copying inside the cached function on every hit would cancel most of the benefit.
saying these in an interview costs you the question
- Says the function body runs and then fails to store
- Claims any object can be a cache key
- Wraps a mutable object in a custom hash and calls it fixed
- Thinks f(3) and f(x=3) share a cache entry
- Converts a huge list to a tuple on every call without noticing the cost
- Assumes the cached return value is copied per caller