Here is a bug that is easy to ship. A function takes a dict of default settings and hands each new user a copy. Someone adds a tag to one user. Every future user now has that tag too.
The code looks harmless. Each block below runs on its own, and the output under it is what Python 3.13 printed.
defaults = {"theme": "dark", "tags": ["new"]}
user_a = defaults.copy()
user_a["tags"].append("beta")
print("1. shallow copy, then append")
print(" defaults:", defaults)
print(" user_a :", user_a)
print(" same inner list?", user_a["tags"] is defaults["tags"])
1. shallow copy, then append
defaults: {'theme': 'dark', 'tags': ['new', 'beta']}
user_a : {'theme': 'dark', 'tags': ['new', 'beta']}
same inner list? True
.copy() made a new dict, so user_a is not defaults. But the new dict holds the same list object under "tags". You copied the outer box and kept pointing at the same contents. That is what "shallow" means. The rest of the interview answer is where it bites.
When you explain it out loud, draw it: two dict boxes, each with an arrow labelled tags going to the same list box. The drawing usually answers the follow-up questions. The is check in the code is the same picture in text. It asks whether two names point at one object, not whether the contents look equal.
The same thing happens with the other usual ways to copy. list(x), x[:] and x.copy() all build a new outer list and share the inner ones:
nested = [["a"], ["b"]]
clones = [list(nested), nested[:], nested.copy()]
print("inner lists shared with the original:", [c[0] is nested[0] for c in clones])
inner lists shared with the original: [True, True, True]
The bug is not specific to .copy(): it appears wherever you wanted an independent copy and wrote the cheap one.
Schoolabe's Python interview questions page lists "What is the difference between shallow copy and deep copy in Python?" in its sample of the question bank. It describes the category as "copying vs references" gotchas that interviewers probe with short follow-ups. Interviewers rarely stop at a definition; they show five lines and ask what prints.
Follow-ups worth rehearsing
The fix everyone knows is copy.deepcopy, which walks the structure and copies everything it finds:
import copy
defaults = {"theme": "dark", "tags": ["new"]}
user_b = copy.deepcopy(defaults)
user_b["tags"].append("beta")
print("2. deepcopy, then append")
print(" defaults:", defaults)
print(" user_b :", user_b)
2. deepcopy, then append
defaults: {'theme': 'dark', 'tags': ['new']}
user_b : {'theme': 'dark', 'tags': ['new', 'beta']}
Then there is the trap inside the trap: a shallow copy is not always broken. If you replace a value instead of mutating it, the copy is fine.
defaults = {"theme": "dark", "tags": ["new"]}
user_c = defaults.copy()
user_c["theme"] = "light"
user_c["tags"] = user_c["tags"] + ["beta"]
print("3. shallow copy, but rebind instead of mutate")
print(" defaults:", defaults)
print(" user_c :", user_c)
3. shallow copy, but rebind instead of mutate
defaults: {'theme': 'dark', 'tags': ['new']}
user_c : {'theme': 'light', 'tags': ['new', 'beta']}
The + builds a brand new list and assigns it to the key in the copy only. append changes the list both dicts share. A small difference in how you write the line decides whether the bug exists, and that is the cause behind advice like "just always use deepcopy".
The list-of-lists cousin needs no call to copy at all:
grid = [[0] * 3] * 3
grid[0][0] = 9
print("repeated rows ->", grid)
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 9
print("comprehension ->", grid)
repeated rows -> [[9, 0, 0], [9, 0, 0], [9, 0, 0]]
comprehension -> [[9, 0, 0], [0, 0, 0], [0, 0, 0]]
Multiplying a list repeats the reference, not the row. Three rows, one list. The comprehension fixes it because [0] * 3 is evaluated again for every row, so each row is a fresh list. If someone answers the grid question correctly but can't say why the comprehension works, keep asking; that is the part that matters. I think this is the better thing to ask in a mock interview, because it tells you more about whether someone understands references than the shallow versus deep definition does. You see it in grid and DP problems, where people build a table of zeros.
Is deepcopy just the answer, then?
Not by reflex. It copes with cycles, which surprised me: a list that contains itself deep-copies fine, and the last line of the next block checks it. So correctness isn't the objection. Cost is.
import copy
import time
rows = [list(range(100)) for _ in range(2000)]
times = []
for _ in range(10):
start = time.perf_counter()
copy.deepcopy(rows)
times.append((time.perf_counter() - start) * 1000)
print(f"deepcopy of 2,000 lists of 100 ints: {min(times):.0f} to {max(times):.0f} ms over 10 runs")
loop = [1]
loop.append(loop)
clone = copy.deepcopy(loop)
print("self-referencing list deep-copied fine:", clone[1] is clone and clone is not loop)
deepcopy of 2,000 lists of 100 ints: 26 to 35 ms over 10 runs
self-referencing list deep-copied fine: True
(Your timings will differ.)
That timing is from one machine, so run it yourself. The deepcopy figure held steady across my runs at roughly 25 to 35 ms. I won't quote a ratio against a shallow copy, because a shallow copy of that list is so quick that the timer mostly measures noise.
For a settings object copied once per signup, who cares. Inside a loop that runs a million times, you will care a great deal. The cheaper habit is to not share mutable things in the first place: build new lists with + or a comprehension, or store tags as a tuple so append isn't even possible. Then the shallow copy is safe and you never need the slow one.
My tests only used lists, dicts and ints. Objects with custom __copy__ or __deepcopy__ methods, or ones holding file handles or locks, behave differently, and I didn't try any of them. If an interviewer pushes in that direction, say you would check the class before assuming.
Write down what you think prints before you run it.
Written by the Schoolabe team, who build schoolabe.com. Its homepage says tutorials, quizzes and practice are free, and that lessons and practice problems run in the browser.
Top comments (1)
real dev, this is a classic trap that catches developers of all experience levels.
the most valuable takeaway here isn't just "use deepcopy." as you pointed out, the real architectural fix is designing your data structures to avoid shared mutable state in the first place. if
tagswas a tuple instead of a list,.append()wouldn't even be possible, forcing the developer to create a new tuple and completely sidestepping the shallow copy bug.in my own work, i try to default to immutable data structures (or explicitly creating new lists via
+or comprehensions) precisely for this reason. it makes the code's intent clearer and prevents these silent, shared-reference mutations from ever happening.fantastic, practical breakdown of a bug that absolutely slips through code review. 🐯🐍