skip to content

On a Python tuple t = ([1], 2), why does t[0] += [3] raise yet still extend the list?

level: seniorimportance: nice to knowfreq 24%

answer

  1. Augmented assignment is not one step
  2. Read, update in place, store back
  3. The store into the tuple slot fails
  4. extend already ran and returned self
  5. TypeError arrives after the mutation landed

basics

~20 s

Augmented assignment is two steps: the list extends itself in place, then Python tries to store the result back into the tuple slot. The store is what raises TypeError, and nothing undoes the extend that already happened.

solid answer

~40 s

`t[0] += [3]` expands to roughly `t[0] = t[0].__iadd__([3])`. Step one succeeds: `list.__iadd__` is `extend` plus `return self`, so the list is mutated in place. Step two then stores that object back into `t[0]`, and tuple does not support item assignment, so `TypeError` is raised. Python has no rollback around a statement, so you end up with **both** the exception and the mutation - `t` is now `([1, 3], 2)`. Contrast with an integer item, where `t[0] += 1` raises too but nothing was mutated, because integers have no in-place update. The fix is to say what you mean: `t[0].extend([3])` or `t[0].append(3)`, neither of which stores back into the tuple. And never swallow this `TypeError` - a broad handler here hides state that is already half-applied.

code

python · 10 lines
python
t = ([1, 2], "unchanged")
try:
    t[0] += [3]
except TypeError as exc:
    print("raised:", exc)
print(t)                 # ([1, 2, 3], 'unchanged') - the extend already landed

u = ([1, 2], "unchanged")
u[0].extend([3])         # says what it means, stores nothing back
print(u)

go deeper

for a junior

Know that += on an item of a tuple raises, and that t[0].append(x) is the way to add to a list stored inside one. The surprising part - that the list changed anyway - is worth remembering but not expected of you yet.

for a middle

Explain the expansion: evaluate the target, run the in-place update, store the result back. Be able to say which step raises, why list.__iadd__ returning self is what makes the mutation stick, and why the integer case leaves no trace.

for a senior

Show the operational judgement. The exception reports failure after a visible side effect, so any broad handler that swallows it leaves half-applied state and a retry that double-applies it. Say how you would find it in a log and what you would change in both the statement and the handler.

for a principal

Own the guardrail rather than the puzzle: whether records carrying mutable payloads are allowed at all, how retries are made idempotent when a partial mutation is possible, and which exception-handling rules are enforced in CI rather than left to review.

### Augmented assignment is a read, an update, and a store `x += y` is not one atomic operation. Python compiles it into three steps: evaluate the target to get the current object, run the in-place update on it, then **store the result back into the target**. For a subscript target the expansion is roughly: ```python t[0] = t[0].__iadd__([3]) # what t[0] += [3] means ``` For a mutable object, step two does the real work: `list.__iadd__` is `list.extend` plus `return self`. It mutates the list in place and hands back the very same object. Step three then tries to store that object back into `t[0]` - and if `t` is a tuple, item assignment is not supported, so `TypeError: 'tuple' object does not support item assignment` is raised. Nothing rolls the mutation back. Python has no transaction around a statement; step two already completed and its side effect is permanent. So you get an exception *and* the change: ```python t = ([1, 2], "unchanged") try: t[0] += [3] except TypeError: pass print(t) # ([1, 2, 3], 'unchanged') ``` Disassembling the statement shows the shape plainly: the in-place binary operation runs first, and only then does the store-subscript instruction execute and fail. ### Why the list case is nastier than the int case Write `t[0] += 1` where slot 0 holds an integer and you get the same `TypeError` - but integers have no in-place update, so step two merely computed a new object that is then discarded. The state is untouched and the exception is honest. With a list, the exception is a *lie about what happened*: it reports failure after the visible effect has already landed. That asymmetry is exactly what makes the puzzle worth asking. ### Where it does real damage Picture a subscription-billing run whose per-customer record is a tuple `(adjustments, customer)` with a list of adjustments in slot 0, and a worker that does `record[0] += [credit]` inside a broad `except Exception: continue`. The credit is appended, the store raises, the exception is swallowed, and the loop moves on believing the adjustment was not applied. The retry pass applies it again - so an 11-person team's account is credited twice, and the run reports zero errors because the exception never reached a log. Two defects compound here, and the swallowed exception is the worse of the pair: the language told you precisely what was wrong and the handler threw the message away. The tell in production is a `TypeError` mentioning item assignment on a tuple, or - if it is being swallowed - state that is more mutated than the success path can account for. When you find one, check both sides: fix the statement, and fix the handler that hid it. ### Writing it so it cannot happen * If you mean "extend the list", say so: `t[0].extend([3])` or `t[0].append(3)`. No store back into the tuple is attempted, so nothing raises and the intent is explicit at the call site. * If the record is genuinely meant to change, do not store mutable payloads in a tuple; use a list of lists, or a small mutable class. * If it is genuinely meant to be a frozen record, store immutable items so the mechanism cannot arise at all. * Never catch `TypeError` broadly around data-shaping code. It is nearly always a programming error, not a runtime condition to recover from. ### One distinction to keep straight `t += ([3],)` is a different statement. The target there is the *name* `t`, not a slot inside the tuple. Tuple has no in-place add, so Python falls back to `t = t + ([3],)`, builds a brand-new tuple, and rebinds the name. That succeeds - and it changes nothing about the original tuple object, which other references still see unchanged. ### The one-line interview answer `+=` on a tuple item extends the list in place and only then tries to write the result back into the tuple, which raises - so the exception arrives after the mutation is already visible, and any handler that swallows it leaves half-applied state behind.

  • Why does the same statement leave no trace when the tuple item is an integer?
    Because integers have no in-place update. `t[0] += 1` computes a brand-new integer object and then tries to store it into the slot, which raises. Nothing existing was modified, so the failure is clean. The list case differs only because `list.__iadd__` mutates the receiver before the store is even attempted.
  • How does t += ([3],) differ from t[0] += [3] when t is a tuple?
    The target is the name, not a slot. Tuple has no in-place add, so Python falls back to `t = t + ([3],)`, builds a brand-new tuple and rebinds the name. That succeeds and leaves the original tuple object untouched, so any other reference to it sees the old value. It is a rebinding of one name, not a mutation.
  • How would you catch this class of bug before production?
    Treat `TypeError` from data-shaping code as a defect, never as a recoverable condition, so no broad `except Exception` swallows it - log and fail the unit of work. Keep mutable payloads out of tuples in the first place, and make the linting rule for bare or overbroad handlers a blocking one so the message that names the problem actually reaches someone.

saying these in an interview costs you the question

  • Says the tuple is left unchanged after the exception
  • Claims Python rolls back the statement on failure
  • Thinks += on a list creates a new list object
  • Believes the error means the list is immutable
  • Says the item must be an integer for += to work
  • Treats a swallowed TypeError here as harmless

context