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"]){'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 FalseWalk through each line of the output.
defaults["retries"]is still3. Settingshallow["retries"] = 5replaced a key in the new outer dict, which doesn’t touch the original.defaults["headers"]now contains theAuthorizationheader. The shallow copy’s"headers"value is the same dict object as the original’s (theischeck printsTrue), so mutating it through either name changes both.defaults["hosts"]gained"c"for the same reason.deepis untouched.deepcopymade 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)[[1, 0, 0], [1, 0, 0], [1, 0, 0]][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)[[1, 0, 0], [0, 0, 0], [0, 0, 0]]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)[1, 2, [...]] TrueWithout 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.
deepcopyis the honest answer. - The data should never change. Make it immutable instead: tuples instead of lists,
frozenset,types.MappingProxyTypefor 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.