You build a tiny game board. You change one square. Three squares change. Somewhere, a very small ghost appears to be playing tic-tac-toe on your behalf.

The culprit is Python nested list multiplication: a compact way to build a grid that can accidentally give you several references to the same row. The surprise has a name: aliasing, when different references lead to the same object.
Let’s catch the ghost with seven short experiments. Each Python example runs independently in Python 3, needs no extra packages, and includes its expected output. A list is an ordered collection; here, a list of lists represents a board. Python counts positions from zero, so board[0][0] means the first cell in the first row.
Try the experiments yourself with the complete Python script. Copy the code into a new file named haunted_python_list.py in Visual Studio Code or your preferred editor, then run it to explore all seven examples. Everything uses Python’s standard library, so there are no extra packages to install—just a few shared references waiting to surprise you.
1. The board that plays three moves at once
Before running this, predict how many rows will contain an X:
board = [["."] * 3] * 3
board[0][0] = "X"
print(board)
Output:
[['X', '.', '.'], ['X', '.', '.'], ['X', '.', '.']]
One assignment. Three visible changes. A remarkably efficient way to lose against yourself.
The inner expression, ["."] * 3, creates one row. The outer multiplication creates a list with three references to that same row. It does not run the inner expression three separate times.
When board[0][0] changes, Python edits that shared row. Looking through positions zero, one, or two of the outer list reveals the same edited object. Think of one ghost standing between mirrors: several views, one ghost.
2. Same contents, or the very same object?
Python gives us two different questions to ask:
==compares values: do these lists have equal contents?iscompares identity: are these references pointing to the same object?
Try both on two ways of constructing a board:
shared = [[0] * 3] * 3
separate = [[0] * 3 for _ in range(3)]
print(shared[0] == shared[1])
print(shared[0] is shared[1])
print(separate[0] == separate[1])
print(separate[0] is separate[1])
Output:
True
True
True
False
The separate rows look identical, but they are distinct lists. That distinction matters the moment you edit one.
Use == for ordinary value comparisons. Here, is is our detective’s magnifying glass for checking whether two rows share an identity.
3. The fix: give every row its own existence
A list comprehension evaluates an expression for each iteration. Put the row construction inside it:
board = [["."] * 3 for _ in range(3)]
board[0][0] = "X"
print(board)
Output:
[['X', '.', '.'], ['.', '.', '.'], ['.', '.', '.']]
This time, ["."] * 3 runs once per iteration, producing three independent rows. range(3) supplies three iterations; _ is a conventional name for a loop variable whose value we do not need.
Our Python tic-tac-toe project uses independent rows too. If you build your own version, keep that property when shortening the board initialization.
4. Why is multiplying the inner row okay?
Good question. We just used multiplication again.
row = [0] * 3
row[0] = 9
print(row)
Output:
[9, 0, 0]
Integers are immutable: their values cannot be changed in place. This assignment replaces the reference in the first slot with a reference to 9. It does not mutate the integer 0 in the other slots.
Strings, including our "." markers, are immutable too. Repeating them is suitable for this board. Repeating a mutable list is where shared edits become surprising.
Be careful with grids whose cells are themselves lists or dictionaries: independent rows alone do not guarantee independent cells. Construct each mutable cell separately as well.
5. The photocopy that keeps a secret
Perhaps copying the outer list will isolate everything?
original = [[0, 0], [0, 0]]
duplicate = original.copy()
duplicate[0][0] = 9
print(original)
print(duplicate is original)
print(duplicate[0] is original[0])
Output:
[[9, 0], [0, 0]]
False
True
list.copy() makes a shallow copy: a new outer list holding references to the existing inner objects. The folder is new; the documents inside are shared.
For this two-level grid of integers, [row.copy() for row in original] would also copy each row. With deeper mutable nesting, you would need to consider those deeper objects too.
6. Even deepcopy keeps the mirrors
Python’s standard-library deepcopy() recursively copies ordinary nested lists. But here comes the twist: it preserves sharing within the structure it copies.
from copy import deepcopy
original = [[0] * 3] * 3
duplicate = deepcopy(original)
duplicate[0][0] = 7
print(original)
print(duplicate)
print(duplicate[0] is original[0])
print(duplicate[0] is duplicate[1])
Output:
[[0, 0, 0], [0, 0, 0], [0, 0, 0]]
[[7, 0, 0], [7, 0, 0], [7, 0, 0]]
False
True
The original is isolated from the change. Inside the duplicate, however, three references still lead to one copied row.
During a copying pass, deepcopy() remembers objects it has already copied. When it encounters the same row again, it reuses that row’s copy. It reproduces the shared structure instead of turning every appearance into an independent object.
So deepcopy() is not a repair button for an accidentally aliased board. Start with independent rows when that is what your program needs. The copy module documentation explains the copying rules and the special behavior that user-defined classes can provide.
7. Your turn: one ghost escapes
Predict the output before scrolling past this example:
board = [[0] * 3] * 3
board[0] = [9, 9, 9]
board[1][1] = 5
print(board)
Output:
[[9, 9, 9], [0, 5, 0], [0, 5, 0]]
Assigning to board[0] replaces one reference in the outer list. The other two references still lead to the original shared row. Changing board[1][1] therefore appears in those two rows.
Replacing a reference and mutating the object it points to are different operations. Once that clicks, the haunted board becomes surprisingly predictable.
A tiny field guide to un-haunted lists
| Your goal | Use | What to remember |
|---|---|---|
| Three integer placeholders | [0] * 3 | Replacing one slot does not mutate the other integers. |
| Three independent rows | [[0] * 3 for _ in range(3)] | Build a new row on every iteration. |
| Another name for one list | other = original | Assignment does not copy the list. |
| A new outer list | original.copy() | Nested objects remain shared with the original. |
| A recursively copied ordinary list structure | deepcopy(original) | Internal sharing is preserved. |
These rules matter beyond games: a list of device settings, simulation states, or queued tasks can accidentally share mutable data too. When several entries change together, ask one question first: how many objects did I actually create?
For more background, see our programming languages guide and introduction to object-oriented languages. You already have the most useful debugging tool: curiosity about what your references point to.