skip to content

Does putting a list inside a Python tuple make that list immutable?

level: juniorimportance: must knowfreq 62%

answer

  1. Immutability has a depth
  2. Frozen slots, not frozen contents
  3. Rebinding fails, mutating succeeds
  4. The tuple stored a reference, not a copy
  5. As deep as its elements are

basics

~20 s

No. A tuple freezes only its own slots, meaning which objects they point at. A list stored in a tuple is still an ordinary mutable list, so appending to it works; only rebinding the slot fails.

solid answer

~40 s

Tuple immutability is **shallow**: the tuple guarantees that its slots keep pointing at the same objects for its lifetime, and nothing more. If slot 0 holds a list, that list is a separate mutable object, so `t[0].append(x)`, `t[0].sort()` and `t[0][0] = 9` all succeed and the tuple visibly prints differently afterwards. What fails is *rebinding* a slot: `t[0] = []` raises `TypeError: 'tuple' object does not support item assignment`. The rule is that a tuple is immutable exactly as deep as its elements are. For a genuinely frozen record, build it from immutable parts - numbers, `str`, other tuples, `frozenset` - or deep-copy the mutable parts in at construction. The same shallowness applies to `collections.namedtuple` and to a frozen dataclass.

code

python · 9 lines
python
t = (["a"], 2)

t[0].append("b")
print(t)                 # (['a', 'b'], 2) - the list mutated

try:
    t[0] = ["c"]         # rebinding the slot is what tuple forbids
except TypeError as exc:
    print("rebind failed:", exc)

go deeper

for a junior

Be able to state the rule and show it: a list inside a tuple can still be appended to, and only t[0] = ... raises. Know the exact error phrase about item assignment, and be ready to say the tuple stored a reference rather than a copy.

for a middle

Explain the mechanics: the tuple is a fixed array of references, immutability fixes the references, and element semantics belong to the elements. Be ready to say what does raise (item assignment, del, slice assignment) and what does not.

for a senior

Show the production consequence. Records passed across module boundaries share their inner lists with whoever built them, so decide where you copy defensively and where you convert to immutable parts, and be able to name the resulting hashability failure.

for a principal

Own the API-design call: whether a value type in your codebase is shallow-frozen by convention, deeply frozen by a construction-time conversion, or simply documented as caller-owned - and what that costs in copying, review effort and bug class across a team.

### Names, objects, and what a tuple actually stores Every Python name is a reference to an object, and every container stores references rather than values. A CPython tuple is a fixed-size array of pointers, sized once at construction and never resized. Immutability, at the language level, is exactly one promise: **those pointers never change**. Slot 0 will point at the same object for as long as the tuple lives. The promise says nothing whatsoever about the object on the far end of the pointer. So `t = (['a'], 2)` builds a two-slot tuple whose first slot points at a list. That list is an ordinary mutable object that merely happens to be reachable through `t`. `t[0].append('b')` reads slot 0, gets the list back, and calls a list method on it. The tuple is not consulted, not modified, and not even aware anything happened - the pointer in slot 0 is identical before and after, which is all the tuple ever guaranteed. Printing `t` afterwards shows `(['a', 'b'], 2)` because printing follows the pointer and asks the list to describe itself *now*. ### What genuinely does fail `t[0] = ['c']` raises `TypeError: 'tuple' object does not support item assignment`, and so do `del t[0]` and slice assignment: item assignment requires the type to implement the item-store hook, and tuple deliberately does not. Hold the distinction in your head as **rebinding versus mutating**. Rebinding changes *which* object a slot points at; that is the operation tuple forbids. Mutating changes the object itself; that is entirely the business of that object's own type, and a list says yes. The same reasoning explains a case that surprises people even more: the tuple need not be involved at all. ```python inner = ['a'] t = (inner, 2) inner.append('b') print(t) # (['a', 'b'], 2) ``` Nothing was done *through* the tuple here. The constructor copied a reference, not the list, so the caller still holds a live handle on the tuple's contents. ### Why the language is designed this way Deep freezing would force either recursive copying at construction - turning a cheap pointer copy into an unbounded traversal - or a whole-object-graph freeze concept that Python does not have. Neither fits a type whose selling point is being small and cheap. The chosen contract is the one that can be enforced in constant time: the container's own shape is fixed, and element semantics belong to the elements. ### Consequences that bite in real code **Hashability.** A tuple's hash is derived from its items, so a tuple holding a list cannot be hashed and cannot key a dict or join a set - the shallow-immutability rule showing up as a runtime error. **Defensive copies at boundaries.** A function that accepts a "record" tuple cannot assume nobody else holds a reference to the list inside it. If you need a stable snapshot, copy at the boundary with `copy.deepcopy`, or rebuild the record out of immutable parts. **Frozen record types are just as shallow.** `dataclasses.dataclass(frozen=True)` blocks attribute rebinding, not mutation of a list attribute. `collections.namedtuple` and `typing.NamedTuple` instances *are* tuples and inherit precisely this behaviour, which is why a namedtuple field holding a list is a classic source of surprise shared state. ### How to get real depth when you need it Compose the record from pieces that are themselves immutable: numbers, `str`, `bytes`, other tuples, `frozenset` for set-like fields. Convert on the way in rather than trusting callers: ```python def freeze(value): if isinstance(value, list): return tuple(freeze(v) for v in value) if isinstance(value, set): return frozenset(freeze(v) for v in value) return value ``` For a mapping field, `types.MappingProxyType` gives a read-only *view*, which is weaker again - whoever holds the underlying dict can still change what the view shows. That is the same lesson one level further out: in Python, "you cannot change it" and "it cannot change" are different claims. ### The one-line interview answer Tuple immutability is one level deep. It fixes which objects the tuple refers to, never what those objects contain, so a list inside a tuple stays fully mutable and only rebinding the slot raises.

  • If the tuple itself never changed, why does printing it show different contents after the inner list is appended to?
    Because printing follows the reference. The tuple's slot still holds the same pointer it always did; `repr` walks that pointer and asks the list to describe itself at that moment. The tuple's own state - its length and its pointers - is genuinely unchanged. What changed is an object it merely refers to.
  • How would you build a record in Python that really cannot be changed at any depth?
    Compose it only from immutable pieces: numbers, `str`, `bytes`, nested tuples, `frozenset` for set-like fields. Convert on the way in with a recursive freeze rather than trusting callers, or `copy.deepcopy` the mutable parts at construction so no external reference survives. A read-only mapping view via `types.MappingProxyType` is weaker still - the holder of the underlying dict can change what the view shows.
  • Does a frozen dataclass or a namedtuple give deeper immutability than a plain tuple?
    No. `dataclasses.dataclass(frozen=True)` blocks attribute rebinding only, so a list attribute is still fully mutable. `collections.namedtuple` and `typing.NamedTuple` instances literally are tuples, so they inherit the same one-level guarantee. All three freeze the container's own shape, never the objects it refers to.

A tuple is a rack of numbered hooks welded shut: you cannot move a coat to a different hook, but nothing stops anyone rummaging in the pockets of the coat that hangs there.

saying these in an interview costs you the question

  • Says a tuple deep-freezes everything it contains
  • Claims t[0].append raises TypeError on a tuple
  • Thinks the tuple copies its elements at construction
  • Confuses rebinding a slot with mutating the object in it
  • Assumes a frozen dataclass makes list attributes immutable

context