October 28, 2026•6 min read

Python Tuples: Why All Three Points Are the Same Point

Three points walk into a loop and come out identical. The list was never copied three times; the name was. A tuple makes that bug impossible.

Three points, one loop, and the printed output is the same point three times. The loop looks like it does what it says: it shifts each point by its index, so the points should end up at (0, 0), (1, 0), (2, 0).

They end up at (2, 0), (2, 0), (2, 0).

Key takeaways

  • [item] * 3 repeats the reference, not the value, so all three entries point at one object.
  • Mutating any of them mutates all of them; the bug is invisible until you print every entry.
  • A tuple cannot be changed, so sharing one between entries cannot go wrong — that is its main job.
  • x, y = point unpacks, and a tuple can be a dict key because it is hashable.
  • Reach for a tuple for fixed records (a coordinate, an RGB colour, a row you never edit) and a list for anything you intend to grow or edit.

Two files, one command

Write the program, paste the checker, run it until it goes green. No repository, no Docker, python3 only.

Create shift.py:

PYTHON
# shift.py
start = [0, 0]
points = [start] * 3

for step, point in enumerate(points):
    point[0] = step

for point in points:
    print(f"({point[0]}, {point[1]})")

Then check_shift.py:

PYTHON
# check_shift.py
import subprocess
import sys

EXPECTED = ["(0, 0)", "(1, 0)", "(2, 0)"]


def report(results):
    for label, passed, detail in results:
        mark = "PASS" if passed else "FAIL"
        note = f"  <- {detail}" if detail and not passed else ""
        print(f"{mark}  {label}{note}")
    passed = [ok for _, ok, _ in results].count(True)
    print(f"\n{passed} of {len(results)} checks passed")


def main():
    run = subprocess.run([sys.executable, "shift.py"], text=True, capture_output=True)
    crashed = run.returncode != 0
    last_error = run.stderr.strip().splitlines()[-1] if run.stderr.strip() else ""

    results = [("the script runs without crashing", not crashed, last_error)]
    if crashed:
        report(results)
        return 1

    printed = [line.strip() for line in run.stdout.splitlines() if line.strip()]

    results.append(("prints three points", len(printed) == 3, f"got {len(printed)}: {printed}"))
    results.append(("no point is printed twice", len(set(printed)) == len(printed), str(printed)))
    results.append(("the points are (0, 0), (1, 0), (2, 0)", printed == EXPECTED, str(printed)))

    report(results)
    return 0 if all(ok for _, ok, _ in results) else 1


if __name__ == "__main__":
    sys.exit(main())
TEXT
$ python3 check_shift.py
PASS  the script runs without crashing
PASS  prints three points
FAIL  no point is printed twice  <- ['(2, 0)', '(2, 0)', '(2, 0)']
FAIL  the points are (0, 0), (1, 0), (2, 0)  <- ['(2, 0)', '(2, 0)', '(2, 0)']

2 of 4 checks passed

* 3 copies the name, not the list

[start] * 3 builds a list with three entries. Each entry is start — not a copy of [0, 0], but the same list, three times. There is one list in memory and three labels pointing at it, like three keys to one locker.

So point[0] = step on the second pass does not update "the second point". It updates the only list there is, and the first and third entries change with it. The last write wins, which is why every line says (2, 0).

This is what people mean when they say a list is mutable and that assignment does not copy. Two consequences worth remembering:

  • To get three independent lists, you have to create three: [[0, 0] for _ in range(3)].
  • A checker that only looks at the first line would have passed. Printing every entry is what made the bug visible.

Spotting it in your own code is mostly about suspicion at the right moment. Whenever you build a list by multiplying something, ask what that something is. [0] * 3 is fine, because 0 is a number and a number cannot be edited. [start] * 3 is not, because start is a container, and every entry is that one container rather than a copy of it.

A tuple is the same idea with the door welded shut

A tuple is an ordered collection you cannot change after it is created. Round brackets instead of square:

PYTHON
start = (0, 0)

start[0] = 1 now raises TypeError. You cannot break it by accident, so you cannot share it by accident either — three entries pointing at one tuple is not a bug, because nothing can mutate it.

The cost is that "shifting a point" becomes "building a new point" instead of editing the old one:

PYTHON
# shift.py
start = (0, 0)
points = [start] * 3

points = [(point[0] + step, point[1]) for step, point in enumerate(points)]

for point in points:
    print(f"({point[0]}, {point[1]})")
TEXT
$ python3 check_shift.py
PASS  the script runs without crashing
PASS  prints three points
PASS  no point is printed twice
PASS  the points are (0, 0), (1, 0), (2, 0)

4 of 4 checks passed

The comprehension builds a new list of new tuples; nothing is edited in place, so nothing is shared. enumerate gives you the step, the arithmetic produces (0, 0), (1, 0), (2, 0), and the old list is simply replaced.

Where tuples earn their place

A tuple is the right container for a record that describes one thing: a coordinate, an RGB colour, a width and a height, a row you read from a file and never edit. Those values belong together, their order is part of their meaning, and editing one of them in place is not something you should be able to do.

The most common case is returning more than one value. When a function hands back a minimum and a maximum, it hands back a tuple, and low, high = min_and_max(numbers) is unpacking. You have been doing this since the first return you wrote with a comma in it, without needing the name for it.

PYTHON
point = (3, 4)
x, y = point
print(x + y)

That single line also explains the next property. A tuple is hashable, so it can be a dict key: a grid lookup as grid[(3, 4)] is unremarkable. The list version, grid[[3, 4]], raises TypeError — the sentence from the previous article cashing out. Anything that can be put in a set or used as a key has to be unchangeable, because a mutable key would move after the container had already filed it.

The last use is deliberately boring: passing a tuple tells the next person, and the interpreter, that this value is not up for editing. It is not a performance trick and it will not make the code faster. It removes a category of bug, which is worth more.

So: a tuple is a record that should not change; a list is a collection you intend to change. When you need to "modify" a tuple, you build a new one. Half the time that turns out to be a clearer description of what you meant anyway, and the other half is a hint that the value belonged in a dict.

Before the next article

Replace [start] * 3 with [[0, 0] for _ in range(3)] and keep everything else from the broken version. It passes too. Two fixes, one bug — and only one of them stops the next person from editing a shared point.

Get better at shipping code.

Join the other developers getting tech updates, tips, and relaxed tutorials in their inbox every week.

Stay in the loop

Tech updates, coding tips, and relaxed insights straight to your inbox every week.

No spam. Unsubscribe anytime.

Read next

See all

© 2026 Inva.dev.