Skip to content
aviral gupta

// B4.6 · ~25 min · Beginner

References, copies and the mutable default trap

After this lesson you can predict when a change to one variable shows up in another, pick the right kind of copy, and write default arguments that do not leak state between calls.

Lesson 6 of 6 in B4 Data structures

End of the module

You will be able to

  • Explain why b = a does not copy a list, and use is and == correctly
  • Choose between a slice, copy.copy() and copy.deepcopy() for nested data
  • Replace a mutable default argument with the None sentinel pattern
  1. Warm-up · Activity 1 of 7

    Warm-up: which of these objects can be changed in place after they are created, for example with v[0] = 9 or v["k"] = 2? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you run it: what does this print?

    a = [1, 2, 3]
    b = a
    b.append(4)
    print(a)
  3. Practice · Activity 3 of 7

    Start from a = [1, [2, 3]]. Match each line of code to what it gives you.

    import copy
    a = [1, [2, 3]]
  4. Practice · Activity 4 of 7

    What does this print?

    import copy
    
    grid = [[0, 0], [0, 0]]
    shallow = copy.copy(grid)
    deep = copy.deepcopy(grid)
    
    shallow[0][0] = 1
    deep[1][1] = 9
    print(grid)
  5. Practice · Activity 5 of 7

    This function has the mutable default bug: calling add_tag("a") then add_tag("b") returns ['a', 'b'] the second time. Put the lines of the fixed version in order.

    def add_tag(tag, tags=[]):
        tags.append(tag)
        return tags
    1. 1. tags = []
    2. 2. tags.append(tag)
    3. 3. if tags is None:
    4. 4.def add_tag(tag, tags=None):
    5. 5. return tags
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The += line raises an error, which the try block catches. What does the last line print?

    pair = (["foo"], "bar")
    try:
        pair[0] += ["item"]
    except TypeError as e:
        print("TypeError:", e)
    print(pair)
  7. Apply · Activity 7 of 7

    Mini-task. This module has two reference bugs. Run it and you get ['reader', 'admin'] and then ['start', 'stop'] ['start', 'stop']. Fix both functions, then add assert lines that prove each fix.

    def add_event(event, log=[]):
        log.append(event)
        return log
    
    def snapshot(state):
        return state.copy()
    
    state = {"user": "ana", "roles": ["reader"]}
    saved = snapshot(state)
    state["roles"].append("admin")
    print(saved["roles"])
    print(add_event("start"), add_event("stop"))

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

One week of shopping lists

A dict of shopping lists, reached in three ways: through a second name, a shallow copy and a deep copy. Then the original changes, and the output shows which of the three sees what. The function at the top uses the None default, so every call without a basket gets its own list. Change a line and predict the output before you run it.

main.py

import copy


def add_item(item: str, basket: list[str] | None = None) -> list[str]:
    if basket is None:
        basket = []  # a new list for every call without a basket
    basket.append(item)
    return basket


week = {"mon": ["bread"], "tue": ["milk"]}
alias = week  # a second name, not a copy
shallow = week.copy()  # new dict, same inner lists
deep = copy.deepcopy(week)  # new dict, new inner lists

week["mon"].append("eggs")
week["wed"] = ["tea"]

print(alias is week, alias == week, shallow is week)
print(shallow)
print(deep)

print(add_item("apple"), add_item("pear"))
mine = ["salt"]
print(add_item("rice", mine) is mine, mine)

Run it with

python main.py

Output

True True False
{'mon': ['bread', 'eggs'], 'tue': ['milk']}
{'mon': ['bread'], 'tue': ['milk']}
['apple'] ['pear']
True ['salt', 'rice']
  • alias is the same dict as week, so it sees every change.
  • shallow sees "eggs", because its Monday list is week’s Monday list, but not the new key "wed", because it is a separate dict.
  • deep shares nothing, so it still shows the week as it was.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

A basket per call

add_item(item, basket) adds item to basket and returns it; called without a basket, it should start a new one. The starter has the mutable default bug: the second call below prints ['apple', 'pear']. Fix the function so that each call without a basket gets a new list, while a basket passed in, even an empty one, is extended and returned.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    The default list is created once, when def runs. Use None as the default instead.

  2. Hint 2

    Inside the function, create the new list only when basket is None. if not basket would also replace an empty basket.

  3. Hint 3

    def add_item(item: str, basket: list[str] | None = None), then if basket is None: basket = []

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def add_item(item: str, basket: list[str] | None = None) -> list[str]:
    """Add item to basket and return it. Without a basket, start a new one."""
    if basket is None:
        basket = []
    basket.append(item)
    return basket


print(add_item("apple"))
print(add_item("pear"))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def add_item(item: str, basket: list[str] = []) -> list[str]:
    """Add item to basket and return it. Without a basket, start a new one."""
    basket.append(item)
    return basket


print(add_item("apple"))
print(add_item("pear"))

test_main.py

from main import add_item


def test_new_basket_each_call():
    """Two calls without a basket get two different lists"""
    add_item("a")
    got = add_item("b")
    assert got == ["b"], f"the second call returned {got!r}, expected ['b']"


def test_uses_given_basket():
    """A basket passed in is extended and returned"""
    mine = ["x"]
    got = add_item("a", mine)
    assert got is mine, "add_item returned a different list, not the basket it was given"
    assert mine == ["x", "a"], f"the basket is {mine!r}, expected ['x', 'a']"


def test_empty_basket_kept():
    """An empty basket passed in is used, not replaced"""
    mine: list[str] = []
    got = add_item("a", mine)
    assert got is mine, "an empty basket was replaced by a new list; test with is None"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Grids that share nothing

make_grid(rows, cols) should return a grid of zeros whose rows are separate lists, and copy_grid(grid) a copy that shares no row with the original. The starter uses * for the rows and copy() for the copy, and both share rows: the program below prints [[1, 0, 0], [1, 0, 0]]. Fix both functions.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    [[0] * cols] * rows repeats a reference to one row. A list comprehension runs [0] * cols once per row.

  2. Hint 2

    grid.copy() is shallow. Copy each row as well, with a slice or copy.deepcopy().

  3. Hint 3

    return [[0] * cols for _ in range(rows)] and return [row[:] for row in grid]

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def make_grid(rows: int, cols: int) -> list[list[int]]:
    """Return a rows x cols grid of zeros whose rows are separate lists."""
    return [[0] * cols for _ in range(rows)]


def copy_grid(grid: list[list[int]]) -> list[list[int]]:
    """Return a copy of grid that shares no row with it."""
    return [row[:] for row in grid]


board = make_grid(2, 3)
board[0][0] = 1
print(board)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def make_grid(rows: int, cols: int) -> list[list[int]]:
    """Return a rows x cols grid of zeros whose rows are separate lists."""
    return [[0] * cols] * rows


def copy_grid(grid: list[list[int]]) -> list[list[int]]:
    """Return a copy of grid that shares no row with it."""
    return grid.copy()


board = make_grid(2, 3)
board[0][0] = 1
print(board)

test_main.py

from main import copy_grid, make_grid


def test_grid_shape():
    """make_grid(2, 3) is two rows of three zeros"""
    got = make_grid(2, 3)
    assert got == [[0, 0, 0], [0, 0, 0]], f"make_grid(2, 3) returned {got!r}"


def test_rows_independent():
    """Changing one row of a new grid leaves the other rows alone"""
    grid = make_grid(3, 2)
    grid[0][0] = 1
    assert grid == [[1, 0], [0, 0], [0, 0]], f"after grid[0][0] = 1 the grid is {grid!r}; the rows are one shared list"


def test_copy_equal():
    """The copy has the same values"""
    grid = [[1, 2], [3, 4]]
    got = copy_grid(grid)
    assert got == grid, f"copy_grid returned {got!r}, expected [[1, 2], [3, 4]]"


def test_copy_independent():
    """Changing the copy, even inside a row, leaves the original alone"""
    grid = [[1, 2], [3, 4]]
    new = copy_grid(grid)
    new[0][0] = 9
    assert grid == [[1, 2], [3, 4]], f"the original became {grid!r}; the copy still shares its rows"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Keeping a “backup” with b = a

scores = [3, 5]
backup = scores
scores.clear()
print(backup[0])

What Python prints

IndexError: list index out of range

Why, and the fix

backup = scores is a second name for the same list, so clear() empties both. Take a copy before you change the original: backup = scores.copy().

A shallow copy of nested data

settings = {"theme": {"dark": False}}
backup = settings.copy()
settings["theme"]["dark"] = True
assert backup["theme"]["dark"] is False, "the backup changed too"

What Python prints

AssertionError: the backup changed too

Why, and the fix

settings.copy() makes a new outer dict, but backup["theme"] is still the same inner dict. For nested data use copy.deepcopy(settings).

Testing the default with if not

def add_tag(tag, tags=None):
    if not tags:
        tags = []
    tags.append(tag)
    return tags


mine = []
assert add_tag("a", mine) is mine, "the caller's list was replaced"

What Python prints

AssertionError: the caller's list was replaced

Why, and the fix

An empty list is falsy, so if not tags replaces the list the caller passed in. Test for the default itself: if tags is None.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Names refer to objects

Assignment never copies. After y = x, both names refer to the same object. If that object is mutable, such as a list, dict or set, a change made through either name is visible through both. Immutable objects, such as ints, strings and tuples, cannot change, so operations on them produce new objects. The is operator compares identity (the same object); == compares values. Use is for None, and == for numbers and strings.

Shallow versus deep copies

A shallow copy builds a new outer container and fills it with references to the same inner objects. a[:], list.copy(), dict.copy() and copy.copy() are all shallow. copy.deepcopy() builds a new container and recursively copies what it contains, so nested lists and dicts are independent. Deep copies cost more and can copy data you meant to share. Note that [[0] * 2] * 3 is not a grid of copies: it holds three references to one inner list.

Defaults are evaluated once

Default parameter values are evaluated once, when the def statement runs, not on each call. A mutable default such as [] or {} is therefore one shared object, and anything a call adds to it is still there next time. The fix recommended in the Python docs: default to None, and inside the function write if param is None: param = [] to create a fresh object per call.

Sources

Last reviewed September 29, 2026