skip to content

Why does using a list as a dict key raise TypeError, and what works instead?

level: juniorimportance: must knowfreq 76%

answer

  1. Keys are found by hash, not by scanning
  2. A key's hash must never move
  3. Mutable built-ins carry None in __hash__
  4. A tuple hashes its elements recursively
  5. frozenset when order carries no meaning

basics

~20 s

A dict key must be hashable. Lists deliberately have no hash, because their contents can change and a key's hash must stay stable, so Python raises TypeError. Use a tuple of hashable items, or a frozenset when order is irrelevant.

solid answer

~40 s

A dict stores a key by its hash, so a key must be *hashable*: a `__hash__` that returns the same value for the object's lifetime and agrees with `__eq__`. CPython enforces this by giving the mutable built-ins no hash at all — `list.__hash__`, `dict.__hash__`, `set.__hash__` and `bytearray.__hash__` are all `None` — so `hash()` raises `TypeError`; on 3.14 a dict subscript reports `cannot use 'list' as a dict key (unhashable type: 'list')`. The immutable built-ins are hashable, but a `tuple` is hashable only if every element is, so `('cpu', ['load'])` still fails. The fixes are a tuple when order is meaningful, a `frozenset` when it is not, or a canonical string you control. Set elements obey the identical rule.

code

python · 16 lines
python
counts = {}

try:
    counts[["cpu", "load"]] = 1
except TypeError as exc:
    print("list key ->", exc)

counts[("cpu", "load")] = 1          # order is part of the key
counts[frozenset({"cpu", "load"})] = 2  # order is not

try:
    hash(("cpu", ["load"]))          # a tuple hashes its elements
except TypeError as exc:
    print("tuple holding a list ->", exc)

print(len(counts))

go deeper

for a junior

Recall the vocabulary and the fix: keys must be hashable, mutable built-ins are not, and a tuple or frozenset replaces a list. Be able to name the exact exception, TypeError, and reproduce it on demand.

for a middle

Explain the mechanism — the hash picks the slot, so it must be stable and agree with equality — and know that a tuple is hashable only when every element is. Show the frozenset fix for an unordered key.

for a senior

Show where the requirement leaks into design: canonicalizing a composite key once at the boundary, hashable arguments for cached functions, and choosing an explicit key form instead of relying on repr output.

for a principal

Own the convention across a codebase: one canonical key type per domain concept, documented and enforced at the edges, so two subsystems never mint two different keys for the same logical thing.

### Hashable is a contract, not a vibe An object is **hashable** when two things hold: it has a `__hash__` that returns the same integer for the object's whole lifetime, and that hash agrees with `__eq__` — objects that compare equal must hash equal. `dict` and `set` need both halves. A lookup first turns the key into a hash to choose a slot, and only then confirms the candidate with an identity check followed by `==`. If a key's hash could drift after insertion, the entry would sit in a slot nobody ever probes again: `key in d` would answer `False` while the entry is still occupying memory. ### How CPython enforces it on the built-ins Python does not trust a mutable container to promise stability — it removes the capability. For the mutable built-ins, `__hash__` is literally `None`: - `list.__hash__ is None` - `dict.__hash__ is None` - `set.__hash__ is None` - `bytearray.__hash__ is None` `None` in the `__hash__` slot is the documented way to mark a type unhashable, and the `hash()` builtin translates it into `TypeError: unhashable type: 'list'`. On 3.14 the container itself raises a more specific message: subscripting a dict with a list reports `cannot use 'list' as a dict key (unhashable type: 'list')`, and a set literal reports `cannot use 'list' as a set element`. Sets and dicts share exactly one rule here — elements and keys are governed by the same requirement. ### What is hashable Effectively all the immutable built-ins: `int`, `float`, `complex`, `bool`, `str`, `bytes`, `None`, `range`, `frozenset`, plus functions, modules and classes. Instances of ordinary user-defined classes are hashable too, by object identity, unless the class opts out. `tuple` is the case interviewers actually probe, because a tuple is hashable **if and only if every element is**. A tuple's hash is derived from its elements' hashes, so `hash(("cpu", "load"))` is fine while `hash(("cpu", ["load"]))` raises `TypeError` naming the inner `list`. Hashability is a property of the whole value, not of the outer type. ### The `collections.abc.Hashable` trap `isinstance(("cpu", ["load"]), collections.abc.Hashable)` returns `True`. The ABC only inspects the *type* for a non-`None` `__hash__` slot, and `tuple` has one — the failure lives one level down in the value. So the ABC answers a weaker question than the one you asked. The only reliable test is to call `hash(x)` inside a `try`/`except TypeError`. ### Turning a mutable thing into a key - **Order matters** → `tuple(items)`. Remember `("cpu", "load")` and `("load", "cpu")` are two different keys, so if the order is incidental you must canonicalize it (sort at the boundary). - **Order does not matter** → `frozenset(items)`. - **A small mapping**, such as the label set attached to a scraped metric sample → `frozenset(labels.items())`, valid as long as every value is itself hashable. - **A canonical string** — a serialized form you control. Do not reach for `str(some_list)`: `repr` output is not a stability contract, and it silently makes `[1]` and `["1"]` look different in ways that are hard to reason about later. Whatever you pick, decide it once at the edge of the system rather than at each call site; two different canonical forms for the same logical key is the bug this fix usually introduces. ### Why the language made this choice Python could have hashed a list from its current contents. It deliberately does not, because the hash would then change under mutation and strand the entry — a bug that shows up far from the line that caused it. Making the whole family unhashable moves an unfindable-entry bug to an immediate, loud `TypeError` at the moment of insertion. That is also why the fix is never "cache the hash": the requirement is stability *and* agreement with equality. ### Where else the same error surfaces The requirement is not dict-specific. `set` elements obey it; `functools.lru_cache` and `functools.cache` key their memo table on the call arguments, so passing a list to a cached function raises the same `TypeError`; `collections.Counter` counts hashable items; `itertools.groupby` does not hash, but `dict`-based grouping does. Values are unconstrained — a dict may map a tuple key to a list value all day. A candidate who can say "keys are stored by hash, so the key must have a stable hash that agrees with equality, and CPython enforces that by giving mutable built-ins no hash at all" has the whole answer. Everything else — tuples of lists, the ABC quirk, the frozenset fix — falls out of it.

  • Is ('cpu', ['load']) hashable, and what does isinstance against collections.abc.Hashable say about it?
    It is not hashable: a tuple's hash is computed from its elements' hashes, so the inner list makes `hash()` raise `TypeError`. But `isinstance(x, collections.abc.Hashable)` returns `True`, because the ABC only checks the *type* for a non-`None` `__hash__` slot and `tuple` has one. Hashability here is a property of the value, not the type, so the only reliable test is calling `hash(x)` inside a `try`/`except TypeError`.
  • You need to key a counter by a set of scrape labels held in a dict — how do you build a valid key?
    `frozenset(labels.items())` when every value is hashable and order is meaningless, or a sorted tuple of pairs — `tuple(sorted(labels.items()))` — when you want a deterministic, comparable key. The important part is canonicalizing once at the boundary: if one call site sorts and another does not, the same logical label set produces two distinct keys and the counts silently split.
  • Why does Python not just hash a list from its current contents?
    Because the hash would change when the list is mutated, and the entry would then sit in a slot no lookup ever probes again — `key in d` would report `False` for an entry still occupying memory, far from the line that caused it. Making mutable built-ins unhashable converts that silent, delayed corruption into an immediate `TypeError` at insertion. It is also why caching a hash is not a fix: the contract demands stability *and* agreement with equality.

A hash is the shelf number a library assigns from the title on the spine. If the spine can be rewritten after shelving, the catalogue points at a shelf the book no longer sits on — so Python simply refuses to shelve anything with a rewritable spine.

saying these in an interview costs you the question

  • Says lists are unhashable because they are too large
  • Believes every tuple is hashable regardless of its contents
  • Thinks the TypeError comes from == rather than hashing
  • Treats collections.abc.Hashable as proof hash() will succeed
  • Claims set elements follow different rules from dict keys
  • Suggests str(mylist) as a key without any canonical form

context