skip to content

In Python, why does appending to a list parameter change the caller's list while rebinding that parameter does not?

level: juniorimportance: must knowfreq 80%

answer

  1. Two names, one object
  2. A call copies nothing at all
  3. Assignment repoints only the local name
  4. Method calls change the shared object
  5. id() equal means same object

basics

~20 s

A call binds the parameter name to the same object the caller passed; nothing is copied. Assigning to that name repoints only the function's local name, while calling a method like append changes the one shared object.

solid answer

~40 s

Python passes objects by binding names. `f(data)` binds the parameter name, in the function's own local namespace, to the very object `data` already names — no copy is made. Assignment inside the function, `xs = [99]`, creates a new object and repoints only the local name, so the caller is unaffected; a function cannot rebind a name in its caller's frame at all (`global` reaches the module namespace, `nonlocal` an enclosing function's). Calling `xs.append(99)` rebinds nothing — it changes the contents of the one object both names label, which is why the caller sees it. An int argument cannot change because no operation mutates an int object in place, not because ints are passed differently. The model is called call by sharing.

code

python · 11 lines
python
def rebind(xs):
    xs = [99]

def mutate(xs):
    xs.append(99)

data = [1, 2]
rebind(data)
print(data)   # [1, 2]
mutate(data)
print(data)   # [1, 2, 99]

go deeper

for a junior

Be ready to state that a call copies nothing and binds the parameter name to the object passed. Show one list example the caller sees and one int example the caller cannot see, and call assignment 'rebinding'.

for a middle

Explain the mechanics: the parameter lives in a fresh local namespace, global and nonlocal still cannot touch the caller's frame, and comparing id() before and after separates a rebound name from a mutated object.

for a senior

Demonstrate the API judgement this forces. Whether a function mutates a caller's argument or returns a new object should be visible in its name and return value, and stated in its docstring, following the sort/sorted convention.

for a principal

Own the convention across a codebase: decide whether functions borrow mutable arguments or take ownership of them, and make the places that copy explicit rather than leaving each author to guess.

### A call binds a name; it does not copy an object In Python a variable is not a box that holds a value. It is a **name** bound in some namespace to an **object** that lives somewhere else on the heap. Calling `f(data)` does exactly one thing to the argument: it binds the parameter name inside the function's fresh local namespace to *the very object* `data` is already bound to. Nothing is copied — not the list, not its elements, not the int. After the call begins, two names in two different namespaces label one object. Everything else follows from a single distinction: **assignment rebinds a name, while a method call or an item assignment may mutate an object.** ### Rebinding reaches only the local namespace ```python def rebind(xs): xs = [99] # new list object, local name repointed data = [1, 2] rebind(data) print(data) # [1, 2] ``` `xs = [99]` builds a new list and points the function's own local name at it. The binding it overwrites belongs to the function's frame; the caller's name still labels the original list, so the caller sees nothing. There is no ordinary mechanism by which a function can rebind a name in its caller's frame. The `global` statement reaches the module namespace and `nonlocal` reaches an enclosing *function's* namespace — neither of them is the caller's frame. A function therefore has exactly two channels back to its caller: the object it returns, and mutations to objects the caller can also reach. ### Mutation reaches the shared object ```python def mutate(xs): xs.append(99) # same object, contents changed data = [1, 2] mutate(data) print(data) # [1, 2, 99] ``` `xs.append(99)` rebinds nothing. It asks the one shared list object to change its own contents. The caller's name still labels that object, and the object is now different — so the change is visible. The same holds for `xs[0] = 99`, `xs.sort()`, `xs.clear()`, `xs.extend(...)`, for `d[key] = value` on a dict, and for `obj.field = value` on an instance the caller can also reach. ### Why an int argument can never change The usual phrasing — "ints are passed by value, lists by reference" — is wrong, and it hides the real rule. An int is passed exactly the same way a list is: the parameter name is bound to the caller's int object. The difference is that **no operation exists that changes an int object in place**. `n = n + 1` and `n += 1` both compute a *new* int and rebind the local name to it, which is invisible to the caller for the same reason `xs = [99]` was. It is not that the interpreter protects the caller's int; it is that there is nothing you could call on it that would mutate it. ### Proving which one happened, with `id()` `id(obj)` returns an integer that is unique among objects alive at that moment, so *equal ids means the same object*. Capture it before and after and the two cases separate cleanly: ```python def which(xs): before = id(xs) xs = [99] return before == id(xs) data = [1, 2] print(which(data)) # False -> the name was rebound to a different object ``` Compare with a mutating body, where `id(xs)` is unchanged throughout and the caller's `id(data)` matches it the whole time. In an interview this is the cleanest way to show you are reasoning in names and objects rather than in "value" and "reference". ### The name for it Python's model is usually called **call by sharing** or **pass by object reference**: the callee shares the caller's object, and the reference itself is passed by value. It is not pass-by-reference in the sense C++ `int&`, C# `ref` or Pascal `var` parameters mean, because in those languages the callee can assign to the parameter and the caller's *variable* changes. No Python function can do that. It is also not pass-by-value in the sense of copying the argument object, because mutation is visible. Insisting on one of the two labels is what generates the confusion; describing the binding removes it. ### What this changes in the code you write Because a mutable argument is shared, whether a function mutates it is part of its contract and must be obvious. The standard library models the convention: `list.sort()` mutates and returns `None`, while `sorted()` leaves the argument alone and returns a new list; `list.reverse()` mutates, `reversed()` does not. Follow it — a function that mutates its argument should say so in its name and docstring and should not also return the object, because returning it invites callers to believe they got a copy. And when a function *stores* a mutable argument rather than just reading it, the sharing outlives the call: the caller can keep changing an object you now depend on, which is the aliasing bug this model makes possible.

  • Is Python pass-by-value or pass-by-reference?
    Neither, as those terms are normally used. The reference is passed by value: the callee shares the caller's object but cannot assign to the caller's variable, which is what pass-by-reference means in C++ or C#. And nothing is copied, which is what pass-by-value means. The accurate name is call by sharing, or pass by object reference.
  • If a function cannot rebind the caller's name, how can it hand a new object back?
    By returning it, and letting the caller assign. That is the only clean channel besides mutating an object the caller can already reach. `global` and `nonlocal` do not help: they reach the module namespace and an enclosing function's namespace, never the caller's frame.
  • How should a function signal that it mutates its argument?
    By name, docstring and return value, following the standard library's convention: `list.sort()` mutates and returns `None`, while `sorted()` leaves the argument alone and returns a new list. A mutating helper that also returns the object invites callers to believe they were given a copy.

Names are sticky labels, not boxes. Sticking your own label on someone's suitcase and then moving your label to a different suitcase leaves theirs alone; opening the suitcase and adding a shirt does not.

saying these in an interview costs you the question

  • Says Python is pass-by-reference because lists visibly change
  • Claims ints are passed by value and lists by reference
  • Thinks the interpreter copies the argument object on every call
  • Believes assigning to a parameter updates the caller's variable
  • Cannot say what id() before and after would show

context