Mutate or Rebind Lists
Introduction: When a Function Reaches Back Out
Welcome back to Writing Complex Python Functions! We have reached the fourth unit of this course. In the previous unit, above_average was deliberately polite: it read the caller's list, built its own result, and handed that back without disturbing anything. Now we deliberately break that politeness and look at what happens when a function does touch the list it receives.
Our program ships with two short functions that behave in opposite ways: add_bonus_item and with_extra_item. Running it prints exactly three lines:
Here is the puzzle. The first line shows that basket gained an item even though the caller never reassigned it; the second line shows that a very similar-looking function left basket completely alone. Two questions guide the unit: What actually gets passed when we pass a list? and Which operations inside the body reach back out to the caller?
Two Names, One List Object
The answer starts with a fact that surprises almost everyone: passing a list into a function does not copy it. The parameter becomes a new local name bound to the same object the caller supplied. So during the call, the parameter name inside the function and the variable name outside are two labels attached to one object.
Picture one list sitting in memory with two arrows pointing at it, one arrow labeled basket and one arrow labeled cart. Mutating that shared object is visible through every name bound to it; rebinding the local parameter does not affect the caller's variable. When the call finishes, the local name cart disappears, exactly as we learned when studying local scope. The object it was bound to does not disappear: basket still names it, and any in-place change made while cart was bound to it is still there. That single idea explains every behavior in this lesson.

Mutation: add_bonus_item Changes the Caller's List
Our first shipped function takes advantage of that shared object. Notice how little it does.
Two things are missing here, and both are intentional: no new list is ever built, and there is no return at all. The body simply calls a method on the object it was handed. Tracing add_bonus_item(basket, "chocolate"): cart and basket both name ['apples', 'bread'], cart.append("chocolate") changes that one object, and when the function ends, basket names a three-item list.
This is called in-place mutation, and the methods that do it include append, remove, insert, sort, and clear. Since the caller can already see the result, no return is needed; in fact, writing basket = add_bonus_item(basket, "chocolate") would be a bug, replacing our list with the implicit None.
Rebinding: with_extra_item Leaves the Original Alone
The second shipped function looks similar but behaves in the opposite way. The difference is one character: =.
The expression cart + [item] does not modify anything; it builds a brand-new list. The assignment then moves the local cart arrow onto that new object. The caller's list is never part of the assignment, so it cannot change. Tracing bigger = with_extra_item(basket, "coffee"): cart starts on the shared three-item list, then is rebound to a separate four-item list, which travels back through return into bigger.
Here the return is mandatory: without it, the new list would be discarded the moment the function ends.
The Decision Rule: Mutate or Rebind
Those two traces give us a rule we can apply to any function body without running it. Ask whether the body mutates the shared object or rebinds the local parameter name.
| Body does | Caller sees the change? | Needs a return? |
|---|---|---|
Mutating methods: append, remove, sort, clear, insert | Yes | No |
Rebinding: items = items + [x] / items = [x] | No | Yes |
Documented mutating list methods such as append, remove, sort, clear, and insert alter the shared object, so the caller sees the change. Methods like count (or, on other types, get and strip) only read or return new values—they do not mutate. Rebinding the parameter name remains local either way. One consequence is worth stating explicitly: mutation is cumulative. Calling add_bonus_item(basket, ...) three times stacks three new items onto the same list because every call operates on the same object rather than producing a fresh one.
Copying to Protect the Caller's Data
Mutation becomes dangerous when it is an accident. Consider a ranking helper whose docstring promises only to return something:
Given [70, 95, 82, 60, 88], this returns [95, 88, 82], which looks perfectly correct. Meanwhile, the caller's list has been silently reordered into [95, 88, 82, 70, 60], and any later code that depended on the original order is quietly broken. The fix is one habit: copy first, work on the copy.
Any of scores.copy(), scores[:], or list(scores) makes the copy. Be careful with slice assignment, though: scores[:] = sorted(scores, reverse=True) writes back into the caller's list and reintroduces the very bug we are removing.
Deliberate Mutation, Honestly Documented
Sometimes changing the caller's list is the job. A function like remove_zeros(numbers), which strips every zero out of a list of counts, has exactly the shape of add_bonus_item: a mutating body, no return, and a docstring that names the side effect. Two details make it work:
- Removing items from a list while looping over that same list makes the loop skip positions, so we iterate over a copy:
for number in numbers[:]:and thennumbers.remove(number). Note the contrast with the previous section: there, we copied to protect the argument; here, we copy only to traverse safely while deliberately mutating the original. Thisremove-in-a-loop approach is a clear teaching implementation; on lists with many zeros it can be quadratic because eachremovemay scan the list. A linear alternative for later isnumbers[:] = [n for n in numbers if n != 0]. For the small lists we use here, the teaching approach is fine. - A side-effect-only function has no value to give back, so it returns
None, and that is the honest answer rather than a mistake.
This is where docstrings as contracts from the earlier course really earn their keep. Callers cannot see the body; the sentence "the original list is modified in place" is their only warning. Whenever a function changes its argument, that fact belongs in the docstring, not hidden in the implementation.
Conclusion and Next Steps
Three rules carry this unit. First, the parameter is a new local name bound to the same object the caller supplied: mutating that shared object (with methods like append, remove, or sort) is visible to the caller, while rebinding the local parameter is not. Second, whenever the caller's data must survive, copy the list and work on the copy. Third, when a function does mutate its argument, say so in the docstring so the contract stays honest.
In the practices ahead, you will predict mutate-versus-rebind outcomes, repair accidental mutation, and document a deliberate side effect. Then, in the next unit, we will combine validation, loops, and accumulators into longer multi-step functions. Let's go find out which of our predictions Python agrees with.
