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:

After mutation: ['apples', 'bread', 'chocolate']
Original still: ['apples', 'bread', 'chocolate']
New list: ['apples', 'bread', 'chocolate', 'coffee']

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.

basket = ["apples", "bread"]     # 'basket' labels a list object
add_bonus_item(basket, ...)      # inside the call, 'cart' is bound to that same 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.

The names basket and cart both point to the same list object

Mutation: add_bonus_item Changes the Caller's List

Our first shipped function takes advantage of that shared object. Notice how little it does.

def add_bonus_item(cart, item):
    """Append item to the caller's list; the original list is modified in place."""
    cart.append(item)           # 'cart' and 'basket' name the same list object
    # no return needed: the caller can already see the change

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.

add_bonus_item(basket, "chocolate")
print("After mutation:", basket)          # the original list changed
After mutation: ['apples', 'bread', 'chocolate']

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: =.

def with_extra_item(cart, item):
    """Return a new list; the caller's list is left untouched."""
    cart = cart + [item]        # rebinding only changes the local name
    return cart

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.

Rebinding cart creates a new list while basket still points to the original
bigger = with_extra_item(basket, "coffee")
print("Original still:", basket)          # unchanged by the second function
print("New list:", bigger)
Original still: ['apples', 'bread', 'chocolate']
New list: ['apples', 'bread', 'chocolate', 'coffee']

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 doesCaller sees the change?Needs a return?
Mutating methods: append, remove, sort, clear, insertYesNo
Rebinding: items = items + [x] / items = [x]NoYes

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:

def top_three(scores):
    """Return the three highest scores, highest first."""
    scores.sort(reverse=True)   # sorts the CALLER's list
    return scores[:3]

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.

    ordered = scores.copy()     # a separate list object
    ordered.sort(reverse=True)  # sorting the copy cannot touch the argument
    return ordered[:3]

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 then numbers.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. This remove-in-a-loop approach is a clear teaching implementation; on lists with many zeros it can be quadratic because each remove may scan the list. A linear alternative for later is numbers[:] = [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.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal