Guide Python Intermediate

10.11 Copying and Mutability

Why b = a shares one list instead of copying it, the list-specific shallow-copy shortcuts (copy() and l[:]), how mutation is visible through every shared reference including function arguments, and the mutable-default-argument trap.

3 min read

The general mechanics of assignment, shallow copy, and deep copy are covered in depth in 6.5 Copying Objects — this page focuses on what’s specific to lists: the shortcuts, the function-argument consequence, and the classic mutable-default-argument bug.

Assignment Is Not a Copy

b = a makes both names reference the exact same list object — mutating through either name is visible through both.

>>> a = [1, 2, 3]
>>> b = a
>>> b.append(4)
>>> a, a is b
([1, 2, 3, 4], True)

Shallow Copy Shortcuts

l.copy() and l[:] (see 10.3 List Indexing and Slicing) both create a shallow copy — a new outer list, but nested elements are still shared references.

>>> import copy
>>> original = [1, 2, [3, 4]]
>>> shallow = original.copy()     # equivalent to original[:] or copy.copy(original)
>>> original[2].append(99)
>>> shallow          # sees the change -- the nested list is SHARED
[1, 2, [3, 4, 99]]

Use copy.deepcopy() instead whenever the list contains nested mutable objects and full independence is required — see 6.5 Copying Objects for the complete deep-copy example.

Mutability and Function Arguments

Because a list passed into a function is the same shared object, a function that mutates its parameter mutates the caller’s list too — visible after the call returns.

def modify(lst):
    lst.append("modified")

>>> l = [1, 2]
>>> modify(l)
>>> l          # caller's list WAS changed
[1, 2, 'modified']

The Mutable Default Argument Trap

Using a list as a function’s default argument value is a classic, list-specific bug: the default is created once, at function-definition time, and shared across every call that doesn’t override it.

>>> def add_item(item, items=[]):     # BUG
...     items.append(item)
...     return items
...
>>> add_item("a")
['a']
>>> add_item("b")     # surprise -- 'a' persisted!
['a', 'b']

The fix — the same pattern covered generally in 5.15 Common Mistakes — is to default to None and create a fresh list inside the function body:

def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

Quick Interview Answer

b = a shares one list — no copy happens at all. l.copy() and l[:] both make a shallow copy: a new outer list, but any nested mutable objects inside are still shared with the original. copy.deepcopy() is the only one that produces a fully independent structure at every level. This same reference-sharing is exactly why a function that mutates a list parameter changes the caller’s list too, and it’s the root cause of the mutable-default-argument bug: a default list is created once at definition time, not fresh per call, so it silently accumulates state across calls unless None is used as the sentinel instead.”

Common Mistakes

  • Assuming l.copy() produces a fully independent list — it’s shallow; nested mutable objects (like inner lists or dicts) are still shared.
  • Writing def f(items=[]) and being surprised the default list accumulates values across unrelated calls — use items=None and create the list inside the function instead.
  • Passing a list into a function and not expecting it to change after the call — if the function shouldn’t mutate the caller’s list, pass a copy explicitly (f(l.copy())) or have the function copy internally.

Add More Questions to This Guide

Know a question that should be here? Share it and help the community!

Open Google Form