skip to content

Why does `t[0] += [2]` on `t = ([1],)` raise TypeError yet still extend the list?

level: middleimportance: must knowfreq 48%

answer

  1. One line, two separate steps
  2. Order matters: which step fails?
  3. The inner object is not immutable
  4. Augmented assignment always stores back

basics

~20 s

The list inside the tuple is extended first by list.__iadd__; augmented assignment then stores the result back with t[0] = ..., and a tuple rejects item assignment. The TypeError is raised after the mutation has already happened.

solid answer

~40 s

`t[0] += [2]` is not one operation, it is three: fetch `t[0]`, apply the in-place add, store the result back into `t[0]`. The fetch hands you the inner list; `list.__iadd__` extends that list in place and returns the same object; then the store step calls the tuple's item assignment, which does not exist, so `TypeError: 'tuple' object does not support item assignment` is raised. Nothing rolls back — the list really did grow. This is not a bug and not a special case: augmented assignment *always* ends in a store, and here the target of that store is immutable while the object it holds is not. Disassembling the line with `dis.dis` shows the in-place op followed by a store-to-subscript step. If you want the mutation without the error, call `t[0].extend([2])`.

code

python · 6 lines
python
t = ([1],)
try:
    t[0] += [2]
except TypeError as exc:
    print("raised:", exc)
print(t)   # ([1, 2],)

go deeper

for a junior

Know that a tuple fixes which objects it holds, not their contents, so a list inside a tuple can still be changed. Recognising the snippet and predicting ([1, 2],) with an exception is already a strong answer at this level.

for a middle

Explain the three steps — fetch, in-place add, store back — and say which one raises. Be able to point out that t[0].extend([2]) works precisely because it skips the store, and that an immutable element would mutate nothing at all.

for a senior

Generalise it: any target with a failing store and a mutable value shows this, including a read-only property. Show that you would spot the half-applied state in a code review and would not rely on an exception meaning nothing happened.

for a principal

Treat it as a data-shape decision: nesting mutable containers inside supposedly frozen ones gives false guarantees across a codebase. Be ready to argue for genuinely immutable structures at boundaries, and to say what enforcement you would put behind that rule.

### The line is three steps, not one Every augmented assignment compiles to the same shape: **load the target, apply the in-place operation, store the result back into the target**. For a plain local name the store is invisible. For a subscript target it is not, because storing into `t[0]` means calling the container's item assignment. Run `dis.dis("t[0] += [2]")` and the sequence is plain to see: the tuple and the index are loaded, the element is fetched, the in-place binary operation runs, and then a store-to-subscript instruction executes. Three distinct things, in that order, with no transaction around them. ### Why the list grows The element fetched from the tuple is a `list`. `list` implements `__iadd__`, which extends the existing list object with the items of the right-hand iterable and returns `self` — the same object the tuple is still holding. That mutation is complete and permanent the moment the in-place operation returns. ### Why the error is raised anyway The store step then runs. `tuple` implements no item assignment, so the interpreter raises `TypeError: 'tuple' object does not support item assignment`. Python does not undo the earlier mutation, because it has no notion of undoing an arbitrary in-place operation; it does not even know one occurred. ```python t = ([1],) try: t[0] += [2] except TypeError as exc: print("raised:", exc) print(t) # ([1, 2],) - extended anyway ``` ### The immutability that a tuple actually gives you A tuple freezes *which objects* it holds, not the state of those objects. `t[0]` will always be that same list; nothing stops the list itself from changing. Candidates who answer "tuples are immutable, so nothing happened" have confused the container with its contents, and that is exactly the confusion this question is designed to expose. ### The clean forms * `t[0].extend([2])` mutates and never raises: a method call is one step, with no store back into the tuple. * `t[0].append(2)` likewise. * `t = (t[0] + [2],)` builds a new list *and* a new tuple, leaving the original untouched. Note what does **not** help: `t[0] = t[0] + [2]` raises the same `TypeError`, and this time nothing is mutated, because the `+` built a new list that only the failed store would have installed. ### The same trap without a tuple The pattern generalises to any target whose store can fail while the fetched object is mutable. A read-only `property` is the everyday case: ```python class Job: def __init__(self): self._stops = [1] @property def stops(self): return self._stops job = Job() try: job.stops += [2] except AttributeError as exc: print("raised:", exc) print(job._stops) # [1, 2] - mutated before the store failed ``` Here the fetch returns the underlying list, `__iadd__` extends it, and only then does the store fail with `AttributeError` because the property has no setter. Same shape, different exception. A namedtuple field behaves like the tuple case for the same reason. ### And the mirror image, with an immutable element `t = (1,)` then `t[0] += 2` raises the same `TypeError`, but this time nothing at all changed: `int` has no in-place hook, so `__add__` built a new integer that the failed store never installed. Being able to state both halves — mutation-then-failure versus no-mutation-then-failure — is what separates a memorised answer from an understood one. ### What the interviewer is checking Three things, in order of value: that you know augmented assignment ends in a store; that you know the in-place hook has already run and mutated by the time the store fails; and that you do not describe the result as a language bug. It is a direct, unavoidable consequence of two independent rules — in-place operators mutate when they can, and augmented assignment always writes back — meeting in one line.

  • Why does `t[0].extend([2])` succeed where `t[0] += [2]` raises?
    `t[0].extend([2])` is a method call on the object the tuple holds — one step, no assignment. `t[0] += [2]` is a fetch, an in-place add, and then a store into `t[0]`, and it is that final store that the tuple rejects. The mutation is identical in both; only the extra store distinguishes them.
  • Does the same trap exist outside tuples?
    Yes, wherever the fetch succeeds and the store cannot. A read-only `property` holding a list is the common case: `obj.items += [x]` extends the list and then raises `AttributeError` because there is no setter. Any object whose `__setitem__` or `__setattr__` refuses will show the same mutate-then-fail shape.
  • For `t = (1,)`, does `t[0] += 2` also change something before it raises?
    No. `int` has no in-place hook, so Python falls back to `__add__`, which builds a new integer object. Only the store would have installed it, and the store raises `TypeError`. The tuple is untouched. The difference from the list case is entirely down to whether the element's type can mutate itself.

saying these in an interview costs you the question

  • Says the tuple is immutable so nothing changed
  • Calls the behaviour a CPython bug
  • Thinks the error is raised before the list is touched
  • Believes a tuple freezes the objects it holds
  • Claims `t[0].extend([2])` raises for the same reason
  • Expects Python to roll back the in-place operation

context