Why can't a Python set contain a list, and how does frozenset help?
answer
- Where does a set put its members?
- Membership is found by hash bucket
- Mutable containers refuse to hash
- __hash__ is None on list and set
- The immutable twin of set
basics
~20 sA set stores its members in a hash table, so every element must be hashable, and lists are mutable and deliberately unhashable. frozenset is an immutable, hashable set, so it can sit inside another set or serve as a dict key.
solid answer
~50 s`set` and `dict` both find members by hash bucket, so anything stored in a set must have a working `__hash__` whose value stays fixed while the object is stored. `list`, `dict`, `set` and `bytearray` define equality by content, so a stable hash is impossible and CPython sets their `__hash__` to `None`. On 3.14 the failure is explicit: `{[1, 2]}` raises `TypeError: cannot use 'list' as a set element (unhashable type: 'list')`. `frozenset` is the fix — the same set behaviour with no mutating methods, so its membership is fixed and it can hash itself from its members' hashes, order-independently. That makes a `frozenset` legal as an element of another set, as a dict key, and as an argument to a hashing cache. Its own elements must still be hashable: `frozenset([[1]])` fails for exactly the same reason.
code
python · 9 linestry:
tags = {["billing", "urgent"], ["vip"]}
except TypeError as exc:
print(exc)
frozen = frozenset(["billing", "urgent"])
seen = {frozen}
routes = {frozen: "finance-queue"}
print(routes[frozenset(["urgent", "billing"])])go deeper
Be ready to say that set members must be hashable, that a list is not, and that frozenset is the immutable set you reach for instead. Recognizing the TypeError on sight is the bar here.
Explain the mechanism: the hash table needs a hash that never changes, mutable containers set hash to None, and hashability is recursive through tuples. Show frozenset used both as a set element and as a dict key.
Connect the rule to real failures — a set of coordinate lists that will not build, a lookup keyed on a mutable collection — and show where you convert to a frozen form: once, at the boundary where data enters the hashed structure.
Own the API-design angle: whether your own types should be hashable at all. Opting in means freezing whatever fields equality reads. Value-like objects hash well; entities that mutate should stay unhashable rather than publish a hash that lies.
### The rule behind the rule A `set` is a hash table. When an object goes in, CPython calls `hash()` on it, reduces the result to a slot index, and parks the object in that slot. When you later ask `x in s`, the interpreter hashes `x`, jumps straight to that slot, and uses `==` only against the handful of objects that happened to land in the same place. That is the whole source of the near-constant-time membership test, and it holds together on one assumption: **the hash of a stored object never changes while it is stored.** So every element of a `set` — and every key of a `dict` — must be *hashable*: it must have a working `__hash__`, and that value must stay fixed for the object's lifetime. ### Why the mutable built-ins are excluded Python's mutable containers define equality by content: two distinct list objects holding `[1, 2]` compare equal. Hashing carries a hard invariant — objects that compare equal must hash equal — so a content-based `__eq__` forces a content-based hash, and a content-based hash changes the moment you append. An object like that in a hash table becomes unreachable: it sits in the slot its old hash chose while every lookup goes to the slot its new hash chooses. Rather than let you build that quietly broken structure, CPython sets `__hash__` to `None` on `list`, `dict`, `set` and `bytearray`. The result is a loud failure at insertion time instead of a silent lookup miss much later. ### What 3.14 changed about the message The rule is as old as sets, but the diagnostics improved. On 3.14, building `{[1, 2]}` reports `cannot use 'list' as a set element (unhashable type: 'list')`, and `{[1, 2]: 3}` reports `cannot use 'list' as a dict key (...)`. On 3.13 and earlier both said only `unhashable type: 'list'`, which named the culprit but not the context. A bare `hash([1, 2])` still produces the short form on 3.14, because there is no container to name. ### Hashability is recursive It is not a property of the outermost type. A `tuple` computes its hash by combining its elements' hashes, so `(1, 2)` is hashable and `(1, [2])` is not — the inner list has no hash to combine. The same applies to `frozenset`: `frozenset([[1]])` fails to construct. When you convert data at a boundary to make it set-worthy, you have to convert all the way down, not just the top level. ### Enter frozenset `frozenset` is the built-in immutable set. It takes any iterable, deduplicates by hash and equality exactly as `set` does, supports every read operation `set` supports, and simply has no mutating methods. Because its membership is fixed at construction, it can safely publish a `__hash__` derived from its members' hashes — and it does so order-independently, so `frozenset({1, 2})` and `frozenset({2, 1})` are equal and hash identically. That single property unlocks the three things a plain `set` cannot do: * be an element of another set, which is how you build a set of sets; * be a key in a `dict`; * be an argument to anything that hashes its inputs, such as a memoizing decorator. ```python groups = {frozenset({"a", "b"}), frozenset({"c"})} print(frozenset({"b", "a"}) in groups) # True routes = {frozenset({"billing"}): "finance-queue"} print(routes[frozenset(["billing", "billing"])]) # finance-queue ``` ### Two sharp edges First, `set` and `frozenset` compare equal whenever they hold the same members — `frozenset({1, 2}) == {1, 2}` is `True`. Equality was never the constraint; hashability is. So a dict keyed on a frozenset cannot be looked up with the equal plain set: `d[{1, 2}]` raises `cannot use 'set' as a dict key (unhashable type: 'set')`. Second, set *containment* special-cases this. `{1} in {frozenset({1})}` returns `True`, because when a set's membership test receives an unhashable `set`, CPython retries with a temporary frozenset copy instead of raising; `remove` and `discard` do the same. It is a convenience limited to those three set methods, and it does not extend to dict lookups — which is exactly why the two behaviours look inconsistent from the outside. ### Doing it in real code The practical move is to convert once, at the point where data enters the hashed structure: build a `tuple` from a list of coordinates before collecting points in a set, build a `frozenset` from a collection of labels before using it as a key. Converting at the boundary keeps the mutable, ergonomic form in the code that manipulates the data, and the frozen, hashable form in the code that indexes it.
- Why are tuples usually hashable but not always?A tuple hashes by combining its elements' hashes, so it is hashable only when every element is. `(1, 2)` works as a set element; `(1, [2])` raises `TypeError` because the inner list has no hash to combine. The rule is recursive — hashability is a property of the whole object graph the hash reads, not of the outermost type — so converting data for a set means converting all the way down.
- Does `frozenset({1, 2}) == {1, 2}` evaluate to True?Yes. `set` and `frozenset` compare equal whenever they hold the same members; equality deliberately ignores the concrete type. But equality was never the constraint — hashability is. So `{frozenset({1, 2}): 'x'}` is a valid dict, and looking that entry up with `{1, 2}` still raises `cannot use 'set' as a dict key`, even though the two keys compare equal.
- Then why does `{1} in {frozenset({1})}` return True if a set is unhashable?Set containment special-cases it. When a set's membership test receives an unhashable `set` argument, CPython retries with a temporary frozenset copy instead of raising, and `set.remove` and `set.discard` do the same. It is a convenience confined to those three set methods; a dict lookup gets no such retry, which is why `d[{1}]` still fails.
A hash table files each object under a label computed from its contents. Letting a mutable object in is like filing a folder under a title someone can rewrite after it is shelved: it is still there, but nobody will ever find it again.
saying these in an interview costs you the question
- Says lists are unhashable because they are slow
- Claims any Python object can go in a set
- Confuses being hashable with being comparable
- Believes every tuple is hashable
- Thinks frozenset makes its elements immutable too