skip to content

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

level: middleimportance: must knowfreq 52%

answer

  1. The wrapper looks the call up in a dict
  2. Dictionary keys have to be hashable
  3. Failure happens before the body runs
  4. Positional and keyword forms key differently
  5. Freeze to a tuple at the boundary

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.

solid answer

~50 s

`functools.lru_cache` stores results in a dictionary keyed by the arguments, so the arguments have to be hashable. Hand it a `list`, `dict` or `set` and hashing the key raises `TypeError: unhashable type` — and it raises it *before* the body runs, which is why the traceback points at the call site rather than at your code. The key is built from the positional arguments plus the keyword arguments in the order given, with no normalisation: `f(10)`, `f(10, 2)` and `f(10, factor=2)` are three separate entries even when the default makes them equivalent calls. With the default `typed=False` the key relies on ordinary hashing and equality, so `1` and `1.0` collide into one entry; `typed=True` puts the argument types in the key to keep them apart. The fix for unhashable input is to normalise at the boundary — freeze a list to a tuple, a dict of options to a tuple of sorted items — and cache the inner function that takes the frozen form.

code

python · 13 lines
python
import functools

@functools.lru_cache
def total(values):
    print("summing")
    return sum(values)

try:
    total([1, 2, 3])
except TypeError as exc:
    print("rejected:", exc)

print(total((1, 2, 3)))

go deeper

for a junior

Recall that arguments must be hashable and that a list, dict or set therefore fails. Knowing the fix — pass a tuple or frozenset — is what is being checked here.

for a middle

Explain the mechanism: the arguments become a dictionary key, so the error is raised on entry, and the key is built without binding defaults or sorting keyword arguments. Be able to say why two spellings of the same call miss twice.

for a senior

Show you can find a low hit rate caused by keying rather than sizing, and describe the normalise-at-the-boundary pattern. Mention that typed=False lets 1 and 1.0 share a result, and that concurrent misses run the body twice.

for a principal

Frame the key as an API contract: what the cache keys on decides what callers must standardise on. Own the guidance about which arguments are allowed near a cached boundary and why value-keyed objects held by a cache deserve a second look.

## The key is a dictionary key The memoizing wrapper is, underneath, a dictionary. To look anything up it must turn the call into a key, and it builds that key out of the arguments themselves. Dictionary keys must be hashable, so the arguments must be hashable. When they are not, hashing the key raises: ```python import functools @functools.lru_cache def total(values): return sum(values) total([1, 2, 3]) # TypeError: unhashable type: 'list' ``` Two details of that failure catch people out. First, it happens on the way *in*: the body never runs, so nothing you wrote is in the traceback and the function looks broken even though the decorator is what refused. Second, it is not a rule about mutability in general but about hashability specifically: `list`, `dict`, `set` and `bytearray` are out; `tuple`, `frozenset`, `str`, `bytes`, numbers, `None` and ordinary objects that inherit the default identity hash are all fine. A tuple *containing* a list is not hashable either, because hashing a tuple hashes its items. ## The key is not normalised The wrapper does not inspect the signature, does not bind defaults, and does not sort keyword arguments. The key is the positional arguments, then a marker, then the keyword arguments in the order the caller wrote them. That has three consequences worth being able to state: ```python import functools @functools.lru_cache def scale(value, factor=2): return value * factor scale(10) scale(10, 2) scale(10, factor=2) print(scale.cache_info()) # three misses, zero hits ``` All three calls mean the same thing and every one of them missed. Callers that write the same call two ways silently halve the hit rate. Swapping the order of two keyword arguments produces yet another key. None of this is a bug — normalising would mean binding the signature on every call, which would cost more than the cache saves — but it is why a cache with a surprisingly low hit rate is often a call-site consistency problem rather than a sizing problem. ## typed, and the 1 versus 1.0 case By default the key relies on ordinary hashing and equality. `hash(1) == hash(1.0)` and `1 == 1.0`, so `f(1)` and `f(1.0)` land on the same entry and the second call gets the first call's result. `True` and `1` collide the same way. If the function's result depends on the *type* — a formatter, a serialiser, anything that branches on `isinstance` — pass `typed=True` and the argument types become part of the key, at the cost of more entries: ```python import functools @functools.lru_cache(typed=True) def kind(x): return type(x).__name__ print(kind(1), kind(1.0)) # int float print(kind.cache_info()) # two misses ``` With `typed=False` that same function would report `int` for both calls — a wrong answer produced by a cache, which is the worst kind. ## Custom objects Your own class is cacheable by default because objects inherit identity-based `__hash__` and `__eq__`: two distinct instances that look identical are two entries. Define `__eq__` without `__hash__` and Python sets `__hash__` to `None`, making instances unhashable and the cache unusable — a common surprise with hand-written value classes. A frozen dataclass, or a class that defines both, keys by value and behaves the way you probably wanted. Note that a value-keyed object stays reachable from the cache for as long as its entry lives. ## Living with unhashable input The standard shape is a thin public function that normalises and a cached inner function that only ever sees hashable arguments: ```python import functools @functools.lru_cache(maxsize=256) def _parse(frozen_options): return dict(frozen_options) def parse(options): return _parse(tuple(sorted(options.items()))) ``` The alternative is to cache on the handful of scalar fields that actually vary, which usually produces a better hit rate anyway. What you must not do is cache on a mutable object and then mutate it: even when it is hashable by identity, the entry now describes a state that no longer exists. ## Concurrency The cache's own bookkeeping is lock-protected, so concurrent calls will not corrupt it. It does *not* promise that the body runs once per key: two threads that miss on the same key at the same time both run the function and both store a result. For a pure function that is wasted work, not a bug — but if you were relying on the cache to deduplicate an expensive side effect, you were relying on something it never offered.

  • What does typed=True change about how functools.lru_cache builds its key?
    It puts the argument types into the key. By default `1` and `1.0` hash equal and compare equal, so they share one entry and the second call receives the first call's result; with `typed=True` they are stored separately, as are `True` and `1`. It matters whenever the function's result depends on the type rather than only on the value, and it costs extra entries.
  • How would you cache a function whose natural argument is a dict of options?
    Normalise at the boundary: keep a public function that converts the dict to a tuple of sorted items or a frozenset, and put the cache on an inner function that takes the frozen form. Often better still, key on the two or three scalar fields that actually vary — a narrower key gives a higher hit rate than freezing the whole structure.
  • Does the cache guarantee the wrapped function runs only once per key under threads?
    No. The cache's internal bookkeeping is lock-protected so it will not be corrupted, but two threads that miss on the same key concurrently will both execute the body and both store a result. If deduplicating the work itself matters, you need your own lock or a future-per-key; the cache only promises correctness of its own structure.

saying these in an interview costs you the question

  • Saying the cache compares arguments by equality without hashing them
  • Expecting the decorator to copy or freeze a mutable argument
  • Believing f(10) and f(10, factor=2) share one cache entry
  • Assuming 1 and 1.0 are kept apart by default
  • Thinking the TypeError comes from inside the wrapped function
  • Defining __eq__ on a cached argument type and expecting hashing to survive

context