PYTHON / LISTS AND TUPLES
Copying lists and aliasing bugs
Tell aliases apart from real copies, copy a list shallowly or deeply on purpose, and trace mutations that change data through a second name.
What you will learn
- Recognise that `b = a` makes a second name for one list, not a second list
- Create an independent top-level copy with a[:], list(a), or a.copy()
- Use copy.deepcopy when the elements are themselves lists or dicts
- Diagnose aliasing with `is` or id(), never with `==`
Understanding Copying lists and aliasing bugs
A Python name is a label attached to an object, and `=` only moves labels around; it never duplicates a list. So after `alias = scores`, there is exactly one list object with two names on it. The important split is between mutating and rebinding: `scores.append(40)`, `scores.sort()` and `scores[0] = 99` all change the one object, so every name pointing at it shows the change, while `scores = [1, 2]` quietly repoints just that one name and leaves the other label on the old list.
When you actually want a second list, you have to ask for one: `scores[:]`, `list(scores)` and `scores.copy()` all build a new list holding the same element references. Those three are interchangeable, and `copy.copy` does the same thing. Note that `==` compares contents and `is` compares identity, so two independent lists can be `==` True forever until one is mutated — which is exactly why `==` is useless for detecting an aliasing bug.
A copy made that way is shallow: the new list is new, but its slots still point at the original element objects. That is fine when the elements are immutable (ints, strings, tuples) because nothing can be mutated in place. It bites when the elements are lists or dicts: `rows[:]` gives you a fresh outer list whose rows are still the very same row objects, so `copy[0].append(x)` shows up in the original. For that case use `copy.deepcopy(rows)`, or for a single nested level, `[row[:] for row in rows]`.
Aliasing bugs concentrate at function boundaries, because passing a list passes the reference. A function that calls `.sort()` or `.append()` on its parameter is editing the caller's data, which is sometimes what you want and sometimes a silent corruption. The standard library signals the difference by naming: `list.sort()` mutates and returns None, `sorted()` returns a new list.
scores = [10, 20, 30]
alias = scores
snapshot = scores[:]
scores.append(40)
scores[0] = 99
print("scores :", scores)
print("alias :", alias)
print("snapshot:", snapshot)
print("alias is scores :", alias is scores)
print("snapshot is scores:", snapshot is scores)
print("snapshot == scores:", snapshot == scores)Assignment copies a reference rather than a list, and an explicit copy duplicates only the outermost level.
Worked examples
Shallow copy versus deep copy
Shows that slicing a list of lists duplicates the outer list only, while deepcopy duplicates every level.
import copy
grid = [[1, 2], [3, 4]]
shallow = grid[:]
deep = copy.deepcopy(grid)
grid[0].append(99)
grid.append([5, 6])
print("grid :", grid)
print("shallow:", shallow)
print("deep :", deep)
print("shallow[0] is grid[0]:", shallow[0] is grid[0])Example explained
Line 1`grid[:]` builds a new outer list, so appending [5, 6] to grid does not reach shallow.
Line 2`grid[0].append(99)` mutates the inner list that grid and shallow both reference, so both show 99.
Line 3copy.deepcopy rebuilt the inner lists too, so deep is untouched by either mutation.
Line 4The final `is` check proves shallow[0] and grid[0] are one object, not two equal ones.
A function that edits its caller's list
Contrasts a mutating helper with one that returns a new list, using the same input.
def double_in_place(nums):
for i in range(len(nums)):
nums[i] *= 2
def doubled(nums):
return [n * 2 for n in nums]
base = [1, 2, 3]
result = doubled(base)
print("after doubled():", base, result)
double_in_place(base)
print("after in place :", base)Example explained
Line 1`doubled` reads nums and builds a separate list, so base survives the call unchanged.
Line 2Inside double_in_place, nums is another name for the caller's list object.
Line 3`nums[i] *= 2` mutates that shared object, so base changes even though nothing was returned.
Line 4A mutating function returning None is the convention that warns callers their data was edited.
Repetition duplicates references
Explains why [[]] * 3 produces three names for one inner list and how a comprehension avoids it.
rows_bad = [[]] * 3
rows_good = [[] for _ in range(3)]
rows_bad[0].append("x")
rows_good[0].append("x")
print("bad :", rows_bad)
print("good:", rows_good)
print("bad[0] is bad[1] :", rows_bad[0] is rows_bad[1])
print("good[0] is good[1]:", rows_good[0] is rows_good[1])Example explained
Line 1`[[]] * 3` evaluates the empty list once and stores that same reference three times.
Line 2Appending through rows_bad[0] therefore appears in all three slots.
Line 3The comprehension runs `[]` on every iteration, creating three distinct list objects.
Line 4The two `is` results confirm the difference: shared object versus separate objects.
Important notes
copy.copy(x) is exactly as shallow as x[:]; only copy.deepcopy walks into nested elements, and it is slower, so reach for it only when the elements are mutable.
With immutable elements a shallow copy behaves like a full copy, because `copy[0] = 5` rebinds a slot in the copy rather than changing any shared object.
Common mistakes
Writing `backup = data` before modifying data, then discovering backup shows every change because there was never a second list to restore from.
Using `data[:]` or list(data) on a list of lists and assuming the rows are safe; mutating one row still shows up in both copies.
Testing `copy == original` to check for aliasing; that is True for two independent lists as well, so it detects nothing and hides the bug until later.
Try it yourself
Change, predict, then run
Start from `board = [['.', '.'], ['.', '.']]`, make `flat = board[:]` and a deepcopy called `real`, then set `board[0][0] = 'X'` and print all three. State which copy changed and why.
Open the Python workspaceCheck your understanding
Given a = [[1, 2], [3, 4]] and b = list(a), then b[0] = [9, 9] and b[1].append(5), what does a hold?
- [[1, 2], [3, 4, 5]]
- [[9, 9], [3, 4, 5]]
- [[1, 2], [3, 4]]
- [[9, 9], [3, 4]]
Show answer
list(a) copies the outer list only. `b[0] = [9, 9]` rebinds slot 0 of b, so a's slot 0 still points at the untouched [1, 2]. But b[1] and a[1] are the same object, so the append is visible through a. Option 3 ([[1, 2], [3, 4]]) is the tempting answer if you believe list(a) duplicated the inner lists as well.