+= Trap: The Exception That Still Mutates2026-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 mutation happens and the exception is raised. The "atomic-looking" statement record[1] += new_items is actually two operations performed in sequence:
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.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.
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.
TypeError you catch is not a guarantee that nothing changed.
