Skip to content

Names are labels, not boxes: aliasing and copies

AdvancedLesson 10 of 149 min

Why changing one Python list can change another: names are labels on objects. See aliasing, shallow and deep copies, and the mutable default argument trap.

It's tempting to picture a variable as a box with a value inside. In Python that picture goes wrong. A better one: objects live on their own, and a name is a label tied to one of them. Assignment ties a label; it never copies the object.

weekend = ["laundry", "call gran"]
todo = weekend            # a second label on the same list
todo.append("bake bread")
print(weekend)            # ['laundry', 'call gran', 'bake bread']

Two names, one object: this is called aliasing. It's often exactly what you want, and it's a puzzle when you didn't expect it.

Mutating versus rebinding

There are two very different ways to "change a variable":

  • mutate the object: xs.append(4), xs[0] = 9, d["k"] = 1. Every label on that object sees the change;
  • rebind the name: xs = something_else. Only that one label moves; the old object, and any other labels on it, are untouched.
nums = [1, 2]
other = nums
nums += [3]          # for a list, += mutates: other sees it
print(other)         # [1, 2, 3]
nums = nums + [4]    # + builds a new list, then rebinds nums
print(other, nums)   # [1, 2, 3] [1, 2, 3, 4]

+= mutates a list in place but has to make a new object for an int, string or tuple, since those can't change. Check it with id() before and after.

Function arguments are labels too

Calling a function ties the parameter name to the caller's object. So a function can mutate a list you pass in, but rebinding the parameter inside does nothing outside:

def add_snack(bag):
    bag.append("apple")      # mutates the caller's list

def replace_bag(bag):
    bag = ["crisps"]         # rebinds the local label only

lunch = ["sandwich"]
add_snack(lunch)
replace_bag(lunch)
print(lunch)                 # ['sandwich', 'apple']

Copies: shallow and deep

list(xs), xs.copy(), xs[:] and copy.copy(xs) make a new outer list, but the items in it are the same objects. That's a shallow copy. With lists inside lists, the inner ones are shared. copy.deepcopy copies all the way down:

import copy
grid = [[0, 0], [0, 0]]
shallow = copy.copy(grid)
deep = copy.deepcopy(grid)
grid[0][0] = 5
print(shallow[0][0], deep[0][0])        # 5 0
print(shallow[0] is grid[0], deep[0] is grid[0])  # True False

The mutable default trap

A default value is worked out once, when def runs, and kept on the function. If it's a list and the function changes it, every later call gets the changed list. You can watch it happen:

def remember(item, seen=[]):
    seen.append(item)
    return seen

remember("a")
remember("b")
print(remember.__defaults__)   # (['a', 'b'],): one list, shared by every call

The fix is a default that can't change, usually None, and a fresh list made inside the function each time it's called.

Try it:Run the starting code first and read its output: both surprises are there.

More in the Python docs: default argument values, how arguments are passed and copy.

Your turn

Fix both surprises in the starting code. Change collect so that each call without a list starts a new one (use None as the default), while a list you pass in still gets added to. Then make plan_b a deep copy of plan_a, so changing plan_b[0][0] to "swim" leaves plan_a alone. Print both plans.

Next →