A scorer wrapped in functools.lru_cache raises TypeError: unhashable type: 'dict' — why, and how do you fix it?
answer
- How does the cache find a previous result?
- Every argument has to be hashable
- Freeze the mapping at the boundary
- frozenset of items, or sorted tuple
- Two equivalent inputs, one key
basics
~20 sThe cache keys entries by the call's arguments, so every argument must be hashable, and a dict is not. Pass a canonical immutable form instead — a frozenset of the mapping's items, or a sorted tuple of them — and unpack it inside.
solid answer
~50 s`functools.lru_cache` stores results in a dict keyed by the call's positional and keyword arguments, so every argument must be hashable; a `dict`, `list` or `set` argument raises `TypeError: unhashable type` on the very first call. The fix is to give the cached function a hashable parameter and keep the mutable structure outside it: convert the mapping to `frozenset(config.items())` when order is irrelevant, or `tuple(sorted(config.items()))` when you want determinism, and rebuild the dict inside the body. A common shape is a thin public wrapper that canonicalises, calling a private cached function that takes the immutable key. Two cautions: the key must be *canonical*, or two equivalent configs become two entries and your hit rate quietly collapses; and caching on a structure the caller can mutate afterwards is a bug waiting to happen, which is exactly why the interpreter refuses it.
code
python · 14 linesimport functools
@functools.lru_cache(maxsize=256)
def score(features):
return round(sum(v for _, v in features), 3)
try:
score({"ctr": 0.2, "bid": 1.5})
except TypeError as exc:
print("rejected:", exc)
key = frozenset({"ctr": 0.2, "bid": 1.5}.items())
print(score(key), score(frozenset({"bid": 1.5, "ctr": 0.2}.items())))
print(score.cache_info().hits)go deeper
Recognise the message: the cache keys results by the arguments, so a dict argument cannot work. Knowing that hashable arguments are required is the takeaway here.
Explain the conversion and why it is shaped that way: freeze the mapping into a frozenset or sorted tuple of items at the boundary, keep the cached function's parameter immutable, and rebuild the structure inside the body.
Show the failure that survives the fix. A non-canonical key never raises, so the cache silently duplicates entries; read cache_info() for hits, misses and currsize before touching maxsize, and weigh the per-call cost of freezing a large mapping.
Decide where caching belongs at all. Keying on an identifier or a version counter the caller already holds often beats freezing a structure per call, and an unbounded cache on a process-lifetime function object is a memory-growth decision, not a detail.
## What the error is telling you `functools.lru_cache` (and `functools.cache`, which is `lru_cache(maxsize=None)`) works by building a key from the arguments of each call and looking it up in a dict. That is the whole mechanism, and it inherits the dict's requirement: **every argument must be hashable**. Pass a `dict`, `list` or `set` and the first call raises before your function body ever runs: ```python @functools.lru_cache(maxsize=256) def score(features): ... score({"ctr": 0.2, "bid": 1.5}) # TypeError: unhashable type: 'dict' ``` Nothing is wrong with the cache. The error is the mutability rule showing up at an API boundary: if the mapping could be edited after the call, an entry stored under its old contents would be wrong for its new contents, and the cache would confidently serve a stale answer. ## The canonical-key fix Give the cached function a parameter that is already immutable, and do the conversion at the boundary: ```python import functools @functools.cache def _score(features): # features: frozenset of (name, value) return round(sum(v for _, v in features), 3) def score(config: dict) -> float: return _score(frozenset(config.items())) ``` The public function keeps the ergonomic dict signature; the private one takes something the cache can key on. `frozenset(config.items())` is a good choice for a flat mapping because it is order-insensitive: two configs built in different orders produce equal keys and equal hashes, so they share a cache entry. When you want a deterministic, orderable key instead, use `tuple(sorted(config.items()))`. Both forms require the *values* to be hashable too. A config holding a nested list needs that list turned into a tuple first, recursively if the structure nests. At that point the honest question is whether the argument should be a structure at all: an immutable value type — a `collections.namedtuple`, or a `dataclasses.dataclass(frozen=True)` — hashes on its fields, documents what the key means, and stops the recursion problem at the source. ## Canonicalisation is the part people get wrong The failure that actually reaches production is not the `TypeError`, which is loud and immediate. It is a key that *works* but is not canonical. `tuple(config.items())` hashes fine and never raises, so it sails through review, but it encodes insertion order into the key. Two callers assembling the same settings in different orders get two entries. Under a sustained peak of around 1,200 requests per minute, an ad-auction bidder built this way looks healthy in every metric except the one that matters: the hit rate is a fraction of what it should be, the cache churns through evictions, and latency climbs for reasons no single trace explains. The rule to state out loud is: **two inputs your code treats as equivalent must produce one key**. ## Diagnosing it A cached function exposes `cache_info()`, which reports hits, misses, `maxsize` and `currsize`. Two numbers answer the question quickly: a hit count near zero across a warm workload means the key is not canonical, and a `currsize` pinned at `maxsize` with hits still climbing means the cache is simply too small for the working set. `cache_clear()` resets it between measurements. Take these readings before tuning `maxsize`, because sizing a cache whose keys do not collapse correctly just spends more memory on the same duplication. ## When not to reach for the conversion Sometimes the argument is genuinely mutable and genuinely large, and freezing it per call costs more than the cached computation saves — the conversion is O(n) in the size of the mapping, on every call, including the hits. In that case, cache at a different level: key on something small and stable that already identifies the input, such as a version counter or an identifier the caller already has, and keep the structure out of the signature entirely. The other case to be careful about is lifetime. `lru_cache` holds strong references to both arguments and results for as long as the entry lives, so an unbounded `functools.cache` on a function taking large keys is an unbounded memory growth path attached to a function object that lives for the whole process. Bound it with `maxsize` unless the key space is genuinely finite and small.
- Why is `tuple(config.items())` a worse cache key than `tuple(sorted(config.items()))`?It encodes insertion order. Two mappings with identical contents built in different orders produce different tuples, so they hash differently and occupy separate entries. The cache still works, which is what makes it dangerous: nothing raises, the hit rate is just quietly far below what you expected.
- How do you tell whether a cache is missing because of bad keys or because it is too small?Read `cache_info()`. Near-zero hits over a warm workload with `currsize` well under `maxsize` points at non-canonical keys, since equivalent calls are not collapsing. Hits climbing steadily with `currsize` pinned at `maxsize` points at an undersized cache for the working set. Use `cache_clear()` between measurements.
- What memory risk comes with an unbounded `functools.cache` on a long-running process?It keeps strong references to every argument and every result for the life of the function object, which is usually the life of the process. If the key space is open-ended, that is unbounded growth. Prefer `lru_cache` with an explicit `maxsize` unless the set of possible keys is genuinely small and finite.
saying these in an interview costs you the question
- Suggests giving dict a __hash__ so it can be a key
- Converts the mapping with str() and calls it a key
- Uses tuple(items) and ignores insertion order
- Assumes any hashable key is automatically a good key
- Never checks cache_info before tuning maxsize
- Leaves an unbounded cache on a long-running process