Python's Tuple-of-List += Trap: The Exception That Still Mutates

2026-05-15

This function is supposed to append items to the list stored inside a record tuple. If it can't update the record, the caller catches the TypeError and assumes nothing happened. Look closely at the output before reading on.

def append_items(record, new_items):
    """record is (id, items_list). Append new_items in-place."""
    record[1] += new_items
    return record

inventory = (42, ["apples", "bread"])

try:
    append_items(inventory, ["cheese", "dates"])
    print("Update succeeded")
except TypeError as e:
    print(f"Update failed: {e}")
    print("Rolling back... (nothing to do, exception was raised)")

print(f"Final inventory: {inventory}")

Expected output (if you believe the exception means "nothing happened"):

Update failed: 'tuple' object does not support item assignment
Final inventory: (42, ['apples', 'bread'])

Actual output:

Update failed: 'tuple' object does not support item assignment
Final inventory: (42, ['apples', 'bread', 'cheese', 'dates'])

The Bug

The mutation happens and the exception is raised. The "atomic-looking" statement record[1] += new_items is actually two operations performed in sequence:

  1. In-place add: Python calls record[1].__iadd__(new_items). For lists, __iadd__ is the same as .extend() — it mutates the list in place and returns the same list object. This succeeds.
  2. Rebind the slot: Python then performs record[1] = <result of step 1>, which calls tuple.__setitem__. This raises TypeError because tuples are immutable.

By the time step 2 fails, step 1 has already mutated the list. The += operator on a mutable element of an immutable container leaves you with a half-applied "transaction" — and worse, code that catches the exception sees a state inconsistent with the apparent failure.

You can confirm this isn't a quirk of the interpreter by disassembling: dis.dis shows the bytecode emits BINARY_OP followed by STORE_SUBSCR. The store fails; the binary op's side effects are already committed.

The same trap snares anyone using namedtuple with a list field, or storing lists as dict values keyed under a frozen wrapper, or freezing config objects with mutable sub-fields.

The Fix

If you really want in-place mutation, call the mutating method directly — it has no rebind step:

def append_items(record, new_items):
    record[1].extend(new_items)   # one operation, no STORE_SUBSCR
    return record

If you want value semantics — produce a new record and leave the old one alone — rebuild the tuple:

def append_items(record, new_items):
    return (record[0], record[1] + new_items)  # concat creates a NEW list

The deeper lesson: += is sugar that desugars differently depending on the type of the target. For an immutable target, it desugars to x = x + y (rebind only). For a mutable target stored in an immutable parent, it tries to do both — and silently leaves you in the worst of both worlds when the rebind half fails.

Key Takeaway: Augmented assignment on a mutable element inside an immutable container performs the mutation before the rebind fails — the TypeError you catch is not a guarantee that nothing changed.

All newsletters