Run it
The broken grid and the correct one side by side with their row identities printed, then the three levels of copying, then the mutable-default-argument bug that has the same cause.
The fix
The comprehension evaluates [0] * 3 on every iteration, so each row is a distinct object.
| Expression | Rows are | Safe to mutate |
|---|
[[0] * 3] * 3 | The same object | No |
[[0] * 3 for _ in range(3)] | Distinct | Yes |
[[0, 0, 0] for _ in range(3)] | Distinct | Yes |
[list(row) for row in template] | Distinct copies | Yes |
copy.deepcopy(template) | Fully independent | Yes |
[[0] * 3] * 3 of immutables only | Shared, unobservably | Read-only |
The last row is why the bug is subtle: sharing immutable objects is harmless and invisible. [0] * 1000 shares one integer object a thousand times and nothing can go wrong, because integers cannot be modified in place. The trouble begins only when the repeated element is mutable.
The same mistake, other costumes
Mutable default arguments — the most consequential version, because the sharing spans calls:
Class attributes — sharing across instances:
dict.fromkeys with a mutable default:
Shallow copies of nested structures:
All four are the same rule: Python assigns and copies references. A copy of a container duplicates the pointers, not the objects behind them.
What multiplication actually does
[x] * 3 builds a list holding the same reference three times. For immutable contents that is invisible — [0] * 3 is fine, because you can never mutate a 0. For a mutable inner object it is a trap, because all three names lead to one object.
The tell is that the bug only appears on write. Building and printing the grid looks perfect; the first assignment to a cell is when it falls apart.
The same idea, three ways
This is one instance of a general rule: assignment and shallow copies duplicate references, never the objects behind them.
b = a — another name for the same list. b = a[:] or list(a) or copy.copy(a) — a new outer list holding the same inner references, so nested contents are still shared. copy.deepcopy(a) — new objects all the way down, at the cost of walking the whole structure.
How to spot it in review
Any * n where the repeated element is a list, a dict, a set or a custom object is suspicious. The same rule explains the mutable default argument bug: def f(acc=[]) creates the list once, when the function is defined, and every call that omits the argument shares it.
The fix in both cases is the same — build the mutable thing at the moment it is needed rather than once, up front.
Copy semantics, precisely
| Operation | Copies | Nested objects |
|---|
b = a | Nothing — a second name | Shared |
a[:], list(a), copy.copy(a) | The outer container | Shared |
copy.deepcopy(a) | Everything, recursively | Independent |
[f(x) for x in a] | Per-element result | Depends on f |
deepcopy is the correct tool when independence is required at every level, and it is not free: it walks the whole structure, tracks already-copied objects to handle cycles, and can be surprisingly slow on large graphs. For a list of lists, [list(row) for row in grid] is faster and clearer.
For grids specifically, NumPy sidesteps the question:
import numpy as np
grid = np.zeros((3, 3), dtype=int)
grid[0, 0] = 1 # no aliasing: one contiguous buffer
Note that NumPy has its own version of the distinction — slices are views, not copies:
a = np.arange(6)
b = a[2:4]
b[0] = 99
a # array([ 0, 1, 99, 3, 4, 5]) -- a changed too
c = a[2:4].copy() # explicit copy when needed
The lesson generalises: whenever a library offers cheap slicing, ask whether you received a view or a copy.
How to detect it
is and id answer the question directly:
Mutate one element and look — the fastest check during debugging. Set grid[0][0] = 1 and print the whole structure.
Watch for * on a list of mutables, and for any default argument or class attribute that is a list, dict or set. Linters flag mutable defaults (B006 in flake8-bugbear, W0102 in pylint), and enabling that rule catches the most damaging variant automatically.
Questions people ask
Why does [0] * 3 work fine? Integers are immutable, so sharing them is unobservable.
Is * ever right for nested lists? Only if you never mutate the inner lists — and then a tuple of tuples states the intent better.
Why does the comprehension fix it? The expression is re-evaluated per iteration, creating a new object each time.
Does this affect tuples? ((0,) * 3,) * 3 is safe, because nothing can be mutated. A tuple containing a list has the same problem.
When do I need deepcopy? When nesting is arbitrarily deep or unknown. For a known two-level structure, a comprehension is faster.
Is list(a) a deep copy? No. Shallow — inner objects stay shared.
Why is a mutable default evaluated once? Default arguments are evaluated at function definition, and stored on the function object.
Recap in one screen
[[0] * 3] * 3 creates three references to one inner list, so mutating one row changes all of them.- Use
[[0] * 3 for _ in range(3)] — the comprehension builds a fresh object per row. - The same rule causes mutable default arguments, shared class attributes, and
dict.fromkeys with a list. - Slicing and
list() are shallow copies; deepcopy is the recursive one, and it is slow. - Test for aliasing with
grid[0] is grid[1], and let a linter catch mutable defaults for you.