Aabha AI Academy All articles

Understand shallow and deep copies of nested Python lists

A picnic plan reveals which lists are shared after assignment, a shallow copy and a deep copy.

Noor is organizing a picnic. Her supply plan has two rows: food first, equipment second.

original = [["sandwiches", "fruit"], ["cups", "napkins"]]

She wants to try a different plan without disturbing this one. She creates a second variable, adds cookies to its food row, and discovers that the original now contains cookies too. She tries original.copy() next. The outer list is different, yet editing a row still changes both plans.

To understand the surprise, follow the objects those names point to. We will compare assignment, a shallow copy and a deep copy using the same small picnic plan. You should already know list indexing and append(). The examples use Python 3 and its standard library; you can also follow the results without running code.

Two names can belong to one plan

Start with assignment:

original = [["sandwiches", "fruit"], ["cups", "napkins"]]
alias = original
alias[0].append("cookies")

print(original)
print(alias is original)

The output is:

[['sandwiches', 'fruit', 'cookies'], ['cups', 'napkins']]
True

Assignment gave Noor another name for the same list. It did not make another list. Editing the food row through alias therefore changed the plan reached through original too. Python's copy documentation

Here, is asks whether two expressions refer to the same object. By contrast, == compares the values in these lists. Separate lists can have equal contents. Python identity comparisons

The next examples use assert to check these relationships. A successful check prints nothing; a false claim raises AssertionError. That lets us test the structure as well as print its contents.

A shallow copy gives Noor a new outer list

Reset the plan before trying another approach:

original = [["sandwiches", "fruit"], ["cups", "napkins"]]
trial = original.copy()

assert trial == original
assert trial is not original
assert trial[0] is original[0]
assert trial[1] is original[1]

list.copy() makes a shallow copy. Noor has a new outer list, but its entries still refer to the existing food and equipment rows. Python's list methods

Imagine two clipboards pointing to the same paper slips. Adding another clipboard does not photocopy the slips. An edit to a shared food slip is visible through either clipboard.

Compare these two operations:

trial.append(["blanket"])
assert len(trial) == 3
assert len(original) == 2

trial[0].append("cookies")
assert "cookies" in original[0]

The first append changes the outer trial list. Only that list gains a third row. The second append reaches the shared food row and changes it. Both plans can still reach that row.

For each edit, ask which exact list receives the change. “I changed the copy” leaves out the part that matters when a list contains other lists.

Replacing a row is a different operation

Start fresh again:

original = [["sandwiches", "fruit"], ["cups", "napkins"]]
trial = original.copy()
trial[0] = ["salad"]

assert trial[0] == ["salad"]
assert original[0] == ["sandwiches", "fruit"]
assert trial[0] is not original[0]
assert trial[1] is original[1]

Noor replaced the first entry in the outer trial list. That entry now points to a new salad row. She did not edit the old food row, so the original keeps its sandwiches and fruit.

The result is uneven: the food rows are separate, while the equipment row remains shared. A shallow copy can be useful when Noor wants to reorder or replace whole rows while keeping the row contents shared. It cannot give her fully independent row contents for every edit.

Copy the rows when the plans must be independent

Noor wants to change both food and equipment without changing the original plan. For these nested lists, deepcopy() supplies that independence:

from copy import deepcopy

original = [["sandwiches", "fruit"], ["cups", "napkins"]]
trial = deepcopy(original)
trial[0].append("cookies")
trial[1][0] = "reusable cups"

print(original)
print(trial)

assert trial is not original
assert trial[0] is not original[0]
assert trial[1] is not original[1]

The output shows that the original is unchanged:

[['sandwiches', 'fruit'], ['cups', 'napkins']]
[['sandwiches', 'fruit', 'cookies'], ['reusable cups', 'napkins']]

Deep copying works recursively through this structure, creating a new outer list and new row lists. The rows are mutable: their contents can change after creation. The strings inside them do not need independent editable copies. Shallow and deep copying

Follow the arrows before you editThree object diagrams compare a nested picnic list before edits. With assignment, original and alias point to one outer list and its two rows. With a shallow copy, original and trial point to separate outer lists that share both inner rows. With deep copy, the two outer lists each point to their own food and equipment rows for this example. Follow the arrows before you edit Arrows are references. Rounded boxes are list objects. Before any edits. Assignment Shallow copy Deep copy original alias outer list food row equipment row One outer list; shared rows original trial outer list outer list food row equipment row Two outer lists; shared rows original trial outer list outer list food row equipment row food row equipment row Separate rows for this example This simple plan contains two distinct rows of strings. Deep copy can preserve sharing inside a copy; it does not make every position unrelated.
Scroll the diagram horizontally to see the full width. The worked example explains the same relationships in text.

These arrows show references before any edits. The two rows in Noor's original plan are distinct lists.

For this particular two-level structure, Noor could also copy each row explicitly:

trial = [row.copy() for row in original]

The list comprehension builds an outer list from newly copied rows. This works here because those rows contain only strings. If another mutable list were nested inside a row, that deeper list would still be shared. Python's list comprehensions

Your turn

Work out both printed values before running this fresh example. Then change the copying line so that the original stays entirely unchanged.

plan = [["tea"], ["water"]]
trial = plan.copy()
trial[0].append("lemon")
trial[1] = ["juice"]

print(plan)
print(trial)
Show the worked answer
[['tea', 'lemon'], ['water']]
[['tea', 'lemon'], ['juice']]

The lemon was appended to a shared inner list. The juice assignment replaced only the second entry of trial.

For independent rows, import deepcopy and use trial = deepcopy(plan) in a fresh run. The original then stays [['tea'], ['water']]. In this two-level example, trial = [row.copy() for row in plan] would also work.

What to remember

Deep copying does not mean every position in a structure becomes unrelated. If the source contains the same row twice, deepcopy() keeps that relationship inside the copy using its record of copied objects. It also has limits for special objects and allows classes to customize copying. Copy behavior and its memo

The complete example script (the complete code is below) checks that repeated-row case as well as Noor's plan. For the everyday question, begin with the edit you intend to make: trace the outer list and its row references, then decide which objects need to be independent. A new name, a new outer list and independent rows are three different results.

Complete optional Python example
"""Original synthetic shallow/deep-copy demonstrations; Python stdlib only."""
from copy import deepcopy


def fresh_plan():
    return [["sandwiches", "fruit"], ["cups", "napkins"]]


def main():
    original = fresh_plan()
    alias = original
    assert alias is original
    alias[0].append("cookies")
    assert original[0] == ["sandwiches", "fruit", "cookies"]

    original = fresh_plan()
    trial = original.copy()
    assert trial == original
    assert trial is not original
    assert trial[0] is original[0]
    assert trial[1] is original[1]
    trial.append(["blanket"])
    assert len(trial) == 3
    assert len(original) == 2
    trial[0].append("cookies")
    assert "cookies" in original[0]

    original = fresh_plan()
    trial = original.copy()
    trial[0] = ["salad"]
    assert trial[0] == ["salad"]
    assert original[0] == ["sandwiches", "fruit"]
    assert trial[0] is not original[0]
    assert trial[1] is original[1]

    original = fresh_plan()
    trial = deepcopy(original)
    trial[0].append("cookies")
    trial[1][0] = "reusable cups"
    assert original == fresh_plan()
    assert trial == [["sandwiches", "fruit", "cookies"], ["reusable cups", "napkins"]]
    assert trial is not original
    assert trial[0] is not original[0]
    assert trial[1] is not original[1]

    # Deep copy preserves intentional aliasing *within* the copied structure.
    row = ["shared"]
    repeated = [row, row]
    duplicate = deepcopy(repeated)
    assert duplicate is not repeated
    assert duplicate[0] is duplicate[1]
    assert duplicate[0] is not row
    duplicate[0].append("new")
    assert duplicate == [["shared", "new"], ["shared", "new"]]
    assert row == ["shared"]

    original = fresh_plan()
    trial = [row.copy() for row in original]
    trial[0].append("cookies")
    assert original == fresh_plan()
    assert all(left is not right for left, right in zip(original, trial))

    # Exercise: shared-row mutation versus outer-entry replacement.
    plan = [["tea"], ["water"]]
    trial = plan.copy()
    trial[0].append("lemon")
    trial[1] = ["juice"]
    assert plan == [["tea", "lemon"], ["water"]]
    assert trial == [["tea", "lemon"], ["juice"]]
    print(plan)
    print(trial)

    plan = [["tea"], ["water"]]
    trial = deepcopy(plan)
    trial[0].append("lemon")
    trial[1] = ["juice"]
    assert plan == [["tea"], ["water"]]
    assert trial == [["tea", "lemon"], ["juice"]]
    print("After deep-copy exercise, original:", plan)
    print("All nested-copy assertions passed.")


if __name__ == "__main__":
    main()