OneBug All write-ups

Python ·

Python copy() of a nested list still changes the original

You copied the list before editing it, and the original changed anyway. list.copy() shares the inner lists; here is the one-level fix.

Test yourself before reading - the write-up below gives it away.

Spot the bug Python
1
# Build the weekend schedule from the standard template.
2
template = [["open", "close"], ["inventory"], ["payroll"]]
3
weekend = template.copy()
4
weekend.append(["deep-clean"])
5
weekend[0].append("stocktake")
6
counts = {i: len(day) for i, day in enumerate(template)}
7
print(counts)
8
# expected: {0: 2, 1: 1, 2: 1}
9
# actual:   {0: 3, 1: 1, 2: 1}  (!)

You did the careful thing. You called .copy() before touching the template, edited only the copy, and the template changed anyway. The maddening part is that the copy is provably real: append a whole new item to it and the original stays put. Then you edit one element in place and both lists move together.

Here is that exact trap, a weekend work schedule built from a weekday template:

# Build the weekend schedule from the standard template.
template = [["open", "close"], ["inventory"], ["payroll"]]
weekend = template.copy()
weekend.append(["deep-clean"])
weekend[0].append("stocktake")
counts = {i: len(day) for i, day in enumerate(template)}
print(counts)
# expected: {0: 2, 1: 1, 2: 1}
# actual:   {0: 3, 1: 1, 2: 1}  (!)

Why does editing a copied nested list change the original?

Line 3 is the bug: list.copy() is a shallow copy. It builds a new outer list, but the slots of that new list point at the same three inner day-lists as template. Line 5 mutates one of those shared inner lists in place, so template grows the shift too. Copy the level you mutate: weekend = [day.copy() for day in template], or copy.deepcopy(template) for arbitrary nesting.

copy() builds a new outer list around the same inner lists

The copy module documentation states it in one sentence: a shallow copy constructs a new compound object and then inserts references into it to the objects found in the original. References, not copies. After line 3 there are two outer lists but still only three day-lists in the whole program, and both schedules point at them:

weekend = template.copy()
print(weekend is template)        # False - the outer list is new
print(weekend[0] is template[0])  # True  - the inner lists are shared

That split is why the snippet lies to you. Line 4 appends to the new outer list, and the outer list really is independent, so template never sees "deep-clean" and counts has three keys, not four. The copy passes the very test you would write to check it. Line 5 does something different in kind: weekend[0] walks through the copy into the shared ["open", "close"] list and appends there, inside an object both schedules own. The comprehension on line 6 is the line that looks guilty, all brackets and enumerate, and it only measures the damage.

The bug stays hidden for as long as your elements are immutable. A list of strings or numbers behaves identically under shallow and deep copy, because nobody can mutate a string in place; every edit is a rebind, and rebinds never travel through a shared reference. Shallow-copied code ships, tests green, and works for months, right up until someone nests a list inside and edits it through the copy.

The schedule template that grew a Saturday shift

Picture a retail rota tool that keeps one weekday template and stamps out per-weekend schedules from it, .copy() each time. A manager adds a stocktake shift to one weekend's opening slot. From that day the weekday template prints the stocktake shift too, and every schedule stamped out afterward inherits it. Payroll flags it first: the same shift, billed five extra times a week. The manager's edit looks innocent in the audit log, the template file on disk has not changed, and the copy call has been in the codebase for a year. What finally gives it away is a REPL check: weekend[0] is template[0] prints True, and the mystery collapses into one shared list.

That one-line probe is worth keeping. Any time a "copy" misbehaves, ask Python whether the first elements are the same object; is answers in a way no amount of staring at values can.

The fix: copy the level you mutate

For one level of nesting, copy the inner lists as you copy the outer:

weekend = [day.copy() for day in template]
print(weekend[0] is template[0])  # False - each day-list is now its own object

For structures of arbitrary or unknown depth, copy.deepcopy(template) recursively copies everything and handles cycles. It costs a full traversal and re-allocation of the whole structure, which is fine for a schedule and noticeable for a large cache, so save it for structures whose depth you cannot see from the call site.

The tempting wrong fix is to swap .copy() for a slice or the constructor, on the theory that a different spelling copies harder:

weekend = template[:]        # same sharing
weekend = list(template)     # same sharing
print(weekend[0] is template[0])   # True either way

All three spellings are the same shallow copy; as of Python 3.13 each prints True on the probe above. The choice between them is style, and rotating through them while the bug persists is a well-worn afternoon.

How to find shallow copies of nested data in your codebase

No linter flags this class, because a shallow copy is usually the right tool; the bug only exists when the copied elements are themselves mutated. So hunt the pattern, then read the intent:

grep -rnE '= *\w+\.copy\(\)|= *list\(\w+\)|= *\w+\[:\]' --include=*.py .

For each hit, ask two questions. Do the elements have mutable insides (lists, dicts, sets, objects)? Does any later code write through an index or key of the copy, like c[0].append or c[k]["field"] = ...? Both yes means the original moves too. The dict twin of this bug wears .copy() on a dict of lists and bites the same way. When reading is faster than grepping, the runtime probe settles it instantly: copy_[0] is original[0].

Related bugs

The pattern here, one object wearing two names, is the through-line of Python's sharing bugs. The barer version is assigning a dict to a "copy" that never was one, where there is no copy at all, only a second name. The classroom classic is a class attribute list shared by every instance, the same shared mutation one scope higher. More Python traps live on the Python bug hub, and the full collection is at bug write-ups.

The copy in your codebase that worries you can be interrogated tonight in one line: pick its first element, ask is, and believe the answer.