skip to content

Augmented Assignment Surprises

x += y mutates a list in place through __iadd__ but rebinds an int or a tuple, and t[0] += [1] manages to do both: it extends the list and then still raises TypeError. Interviewers love that case.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why does `lst += [4]` change the list other names see, but `n += 1` does not?

level: juniorimportance: must knowfreq 62%

answer

  1. Two different outcomes, one syntax
  2. Does the object change, or the name?
  3. Only some types have an in-place hook
  4. Aliases reveal which one happened

basics

~20 s

+= on a list calls list.__iadd__, which extends that same list object in place, so every name bound to it sees the new items. An int is immutable: n += 1 builds a new int and rebinds only the name n.

solid answer

~40 s

Augmented assignment always ends in a store back into the target, but what happens before the store depends on the type. `list` implements `__iadd__`, which extends the existing list (exactly like `list.extend`) and returns the *same* object, so the store rebinds the name to the object it already held and the only visible effect is mutation — every alias, container slot or attribute pointing at that list sees the new items. `int`, `str` and `tuple` define no in-place hook, so Python falls back to `__add__`, which cannot mutate an immutable object and builds a new one; the store then rebinds just the left-hand name. So `lst += [4]` behaves like `lst.extend([4])`, while `n += 1` behaves like `n = n + 1`. Mutation is shared; rebinding is private.

code

python · 9 lines
python
a = [1, 2]
b = a
a += [3]
print(a is b, b)   # True [1, 2, 3]

n = 5
m = n
n += 1
print(n is m, m)   # False 5

go deeper

for a junior

Recall the two outcomes and be able to predict them: a list grows in place and every name pointing at it sees the change, an int or a string is replaced and only the assigned name moves. Practise reading a three-line aliasing snippet aloud.

for a middle

Explain the mechanism, not just the outcome: list.__iadd__ mutates and returns the same object, immutable types fall back to __add__, and the store back into the target happens either way. Mention that += accepts any iterable while + demands a list.

for a senior

Show where this decides API design: a helper that does target += items mutates its caller's data, while target = target + items does nothing visible. Be ready to say which you would choose for a public function and how you would document or defend it.

for a principal

Own the convention. Decide whether shared mutable containers are allowed to cross function boundaries at all, or whether values are returned and rebound by the caller, and be able to justify that choice in terms of the class of bug it eliminates across a whole codebase.

### One syntax, two outcomes `lst += [4]` and `n += 1` are compiled the same way. Python evaluates the target, applies an in-place binary operation, and then **stores the result back into the target**. The store always happens — that detail matters later. What differs between the two lines is whether the operation produced a brand-new object or handed back the object you started with. ### The in-place hook For `x += y` the interpreter first looks for `__iadd__` on `type(x)`. `list` defines it. `list.__iadd__` behaves like `list.extend`: it appends the items of the right-hand iterable onto the existing list object and returns `self`. The store that follows therefore rebinds the name to the very object it was already bound to, which is a no-op. All you observe is that the list grew. `int`, `str` and `tuple` define no `__iadd__` at all. With no in-place hook, Python falls back to the ordinary `__add__` (and, failing that, the right operand's `__radd__`). Neither can mutate an immutable object, so a new object is built and the store rebinds the left-hand name to it. All you observe is that one name now points somewhere else. ### Aliasing is the tell The difference is invisible until a second reference exists: ```python a = [1, 2] b = a a += [3] print(b) # [1, 2, 3] - same object, mutated n = 5 m = n n += 1 print(m) # 5 - n was rebound, m still points at 5 ``` The same asymmetry shows up wherever the reference lives somewhere other than a local name: an attribute on an instance, a module-level list, an element of another list, or a parameter inside a function. ```python def add_all(target, items): target += items # mutates the caller's list def add_all_copy(target, items): target = target + items # rebinds a local; caller sees nothing ``` The second function is almost always a bug when the caller expected an effect, and the first is almost always a bug when the caller did not. ### `+=` on a list is not `+` on a list Because they take different paths, the two operators differ in more than aliasing: * `+=` accepts **any iterable** on the right, because it goes through the extend path: `x = [1]; x += (2, 3)` and `x += "ab"` both work. `+` demands another list — `[1] + (2,)` raises `TypeError`. * `+` builds a new list of the combined length. Building a list by repeated `a = a + [i]` inside a loop copies everything on every iteration and is quadratic; `a += [i]` (or `a.append(i)`) is linear. On a few thousand items the gap is already an order of magnitude. * `+` gives you a fresh object, which is exactly what you want when the input must not be disturbed. ### Strings and the concatenation-in-a-loop trap `s += "x"` semantically rebinds, because `str` is immutable. CPython carries an optimisation that can resize the buffer when the string has only one reference, but that is an implementation detail you must not build on: the portable and predictable way to join many pieces is to collect them in a list and call `str.join` once. ### The other in-place operators follow the same rule `|=` on a `dict` merges in place and mutates (added in Python 3.9); `|=` on a `set` mutates; `|=` on a `frozenset` rebinds, because `frozenset` is immutable. `+=` on a `tuple` rebinds. The question is never "which operator?" but "does this type have an in-place hook, and does anything else hold a reference to the object?" ### The mental model to state in an interview Augmented assignment is *mutate-if-you-can, otherwise-rebind, then-store*. If the target's type can mutate itself, the store is a formality and every holder of that object observes the change. If it cannot, a new object appears and only the name on the left moves. Say those two sentences and you have answered the question; add "and the store still happens either way" and you have set yourself up for the tuple-element follow-up an interviewer will almost certainly ask next.

  • Is `lst += other` always interchangeable with `lst = lst + other` for a list?
    No. `+=` extends the existing list, so aliases see the change, and it accepts any iterable on the right. `lst = lst + other` builds a new list, leaves other references pointing at the old one, and requires a list on the right — `[1] + (2,)` raises `TypeError`. In a loop, repeated `+` is quadratic while `+=` is linear.
  • Does `s += "x"` inside a loop copy the whole string every time?
    Semantically yes: `str` is immutable, so each `+=` rebinds `s` to a new string. CPython has an optimisation that can grow the buffer in place when the string has exactly one reference, but that is an implementation detail and it disappears the moment another name holds the string. Collect the pieces in a list and call `str.join` once.
  • Where does the list-mutating form of `+=` most often surprise people in real code?
    Where the list is reachable from somewhere else: a list passed into a function, a list stored on an instance attribute or at module level, or a list held in another container. `target += items` inside a helper silently edits the caller's data, while `target = target + items` silently edits nothing. Both are bugs when the intent was the other one.

Adding pages to a shared notebook everyone in the room is holding versus writing a new number on your own sticky note: the first is seen by all, the second only by you.

saying these in an interview costs you the question

  • Claims `x += y` is always identical to `x = x + y`
  • Says integers are mutated in place by `+=`
  • Believes other names never observe a `+=` on a list
  • Thinks `+=` on a list returns a new list
  • Confuses rebinding a name with mutating an object
  • Assumes `+` and `+=` accept the same right-hand operands

context

open as a page

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

level: middleimportance: must knowfreq 48%

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.

open as a page

A route-optimisation worker runs `self.routes += batch` while another thread reassigns `self.routes`; what breaks?

level: seniorimportance: should knowfreq 30%

basics

~20 s

self.routes += batch is three steps: load the attribute, extend the list in place, store it back. A concurrent self.routes = [] landing between them is silently undone, because the store rebinds the attribute to the old, now-extended list.

open as a page

What must `__iadd__` return, and what happens if a class only defines `__add__`?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

__iadd__ must return the object to bind, normally self after mutating in place. Omit the return and the name is rebound to None. With only __add__ defined, += falls back to it and rebinds the name.

open as a page