Ch. 9 · Python

Python Shallow Copies and Nested Mutability

Assignment, shallow copy and deepcopy compared on a nested config dict, plus the [[0]*3]*3 grid bug and what deepcopy costs in practice.

~5 min readbeginnerupdated Oct 7, 2026

Python variables are names bound to objects. Assigning one name to another doesn’t copy anything, it just gives the same object a second name. Copying is something you ask for explicitly, and you have two depths to choose from. Most bugs in this area come from asking for a shallow copy and getting surprised that the nested parts are still shared. It’s a beginner-level topic, but it’s a reliable interview question because the wrong mental model produces bugs that look like action at a distance.

Three operations on one config dict

Take a default configuration with a nested dict and a nested list, then compare assignment, .copy() and copy.deepcopy:

import copy

defaults = {"retries": 3, "headers": {"Accept": "application/json"}, "hosts": ["a", "b"]}

alias = defaults
shallow = defaults.copy()
deep = copy.deepcopy(defaults)

shallow["retries"] = 5
shallow["headers"]["Authorization"] = "Bearer abc"
shallow["hosts"].append("c")

print(defaults)
print(deep)
print(alias is defaults, shallow is defaults,
      shallow["headers"] is defaults["headers"], deep["headers"] is defaults["headers"])
python
{'retries': 3, 'headers': {'Accept': 'application/json', 'Authorization': 'Bearer abc'}, 'hosts': ['a', 'b', 'c']}
{'retries': 3, 'headers': {'Accept': 'application/json'}, 'hosts': ['a', 'b']}
True False True False
Text

Walk through each line of the output.

  • defaults["retries"] is still 3. Setting shallow["retries"] = 5 replaced a key in the new outer dict, which doesn’t touch the original.
  • defaults["headers"] now contains the Authorization header. The shallow copy’s "headers" value is the same dict object as the original’s (the is check prints True), so mutating it through either name changes both.
  • defaults["hosts"] gained "c" for the same reason.
  • deep is untouched. deepcopy made new copies of the inner dict and list as well.

That Authorization leak is the kind of bug that matters. If defaults is a module-level constant and one request handler “copies” it to add a user’s token, every later request starts with that token in its headers.

The rule to remember: a shallow copy duplicates one level, the container. Every element in it is the same object as before. Shallow copies come from dict.copy(), list.copy(), list(x), dict(x), x[:], {**x}, [*x] and copy.copy(x). They all behave the same way.

The grid that changes three rows at once

Multiplying a list repeats references, not values. That produces a famous beginner bug:

grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)
python
[[1, 0, 0], [1, 0, 0], [1, 0, 0]]
Text

[0] * 3 builds one inner list. The outer * 3 creates a list holding three references to that one inner list. Changing “row 0” changes the only row there is. [0] * 3 itself is fine because ints are immutable: you can’t change a 0 in place, only replace it.

The fix is to build a new inner list for each row:

grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid)
python
[[1, 0, 0], [0, 0, 0], [0, 0, 0]]
Text

The comprehension evaluates [0] * 3 three times, producing three separate lists. Interviewers like this one because it tests whether you know that * on a list never copies its elements.

What deepcopy does and what it costs

copy.deepcopy walks the object graph, copying every mutable object it finds. It keeps a memo dictionary of objects it has already copied, which does two jobs: it handles cycles, and it preserves sharing within the structure. If two keys pointed to the same list before, they point to the same (new) list afterwards.

a = [1, 2]
a.append(a)          # a list that contains itself
b = copy.deepcopy(a)
print(b, b[2] is b)
python
[1, 2, [...]] True
Text

Without the memo this would recurse forever. With it, the copy contains itself, just like the original.

Immutable objects are treated sensibly. copy.deepcopy((1, 2)) returns the very same tuple, because there’s nothing to protect. A tuple that contains a list is different: copy.copy(t) is t is True, but copy.deepcopy(t) is t is False, since the list inside must be copied and so the tuple holding it needs to be rebuilt.

The price is speed. On a dict of 10,000 entries, each holding a small list, a quick timeit run on Python 3.14 gave about 0.04 ms for .copy() and about 15 ms for deepcopy, a few hundred times slower. Exact numbers depend on your machine, but the ratio is typical. deepcopy is implemented in Python, dispatches on type for every object, and records everything in the memo. In a hot path (per request, inside a loop) that adds up.

It also copies things you might not want copied. A dataclass holding a database connection or a lock will either fail to deepcopy or produce an object that isn’t usable. Classes can customise this with __copy__ and __deepcopy__, which is how libraries make copying their objects safe.

Choosing the right tool

Instead of reaching for deepcopy by default, ask what you actually need.

  • You’ll only replace top-level keys or items. A shallow copy is enough and cheap: cfg = {**defaults, "retries": 5}.
  • You need to change one nested part. Rebuild that path explicitly: cfg = {**defaults, "headers": {**defaults["headers"], "Authorization": token}}. This copies exactly the two dicts you’re changing and shares the rest safely, because nobody mutates the shared parts.
  • You’ll hand data to code you don’t control, which might mutate anything. deepcopy is the honest answer.
  • The data should never change. Make it immutable instead: tuples instead of lists, frozenset, types.MappingProxyType for a read-only view of a dict, or a frozen dataclass. Then nobody needs to copy defensively.

A common misconception is that deepcopy is the “safe” option and shallow copy the “fast but risky” one. The real issue is mutation. If you build new values instead of mutating shared ones, shallow copies are perfectly safe.

Interview exercise: the leaking test fixture

A test module has BASE_USER = {"name": "ana", "roles": ["viewer"]}. Each test does user = dict(BASE_USER) and then user["roles"].append("admin") for the admin cases. Tests pass individually but fail when run in a different order. Why, and what are two fixes?

Answer

dict(BASE_USER) is a shallow copy. Each test gets a new outer dict, but user["roles"] is the same list object as BASE_USER["roles"]. Appending "admin" mutates the shared list, so any test that runs afterwards sees a user who is already an admin. That’s why the result depends on order. One fix is to build the list fresh: user = {**BASE_USER, "roles": [*BASE_USER["roles"], "admin"]}, which never touches the shared data. A cleaner fix is to stop sharing a mutable module-level object at all: a fixture or factory function such as def make_user(**overrides): return {"name": "ana", "roles": ["viewer"], **overrides} returns brand-new objects on every call. copy.deepcopy(BASE_USER) would also work, but it treats the symptom and still leaves a mutable global that the next test author can forget to copy.

More in Python

read ✓Python · easy

Python Mutable Defaults and Shared State

Why def f(x, items=[]) keeps growing between calls, where Python stores that list, the None-sentinel fix, and why tags or [] is subtly wrong.

~5 min readread →
read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc