1.2.21 · D2Introduction to Programming (Python)

Visual walkthrough — Lists — creation, indexing, slicing, mutability

1,717 words8 min readBack to topic

Before line one, three plain words we will use:

  • A value is the actual thing (a list like [1, 2, 3]), living somewhere in memory.
  • A name (or variable) is a label you attach to a value — like a sticky note with "a" written on it.
  • A reference is the invisible string tying a name to a value. Drawing that string is the whole trick.

Step 1 — A name is a label on a string, not a box that holds stuff

WHAT. When you run a = [1, 2, 3], two separate things are born: the list itself (three numbered boxes, sitting in memory) and the name a (a sticky note). A reference — an arrow — connects them.

WHY draw it this way? Beginners imagine the name a contains the list, like a jar. That mental picture is the source of every bug on this page. The correct picture is: the name points at the list from a distance. We need the arrow to be visible so we can later ask "how many arrows point at this list?"

PICTURE. The sticky note a on the left, the row of numbered boxes on the right, one orange arrow between them.

Figure — Lists — creation, indexing, slicing, mutability

Step 2 — b = a copies the arrow, never the boxes

WHAT. Now run b = a. Python evaluates the right side (a), which is the arrow's target — the existing list. Then it points the new name b at that same target.

WHY this and not a copy? Assignment in Python means "make this name refer to that value." It never says "duplicate the value." So b = a costs almost nothing: it just clips a second sticky note onto the same string. Result — one list, two names.

PICTURE. Both sticky notes a and b now have arrows landing on the same box-row.

Figure — Lists — creation, indexing, slicing, mutability

Step 3 — Editing through one name is visible through the other

WHAT. Run b[0] = 99. Python follows b's arrow to the list, walks to box 0, and overwrites what's inside. Because lists are mutable, this happens in place — no new list is made.

WHY it changes a too. There is only one list. a's arrow points at that very same list. So when we peek through a, box 0 now reads 99. Nothing about a's arrow changed — the thing it points at changed.

PICTURE. One list, two arrows; box 0 flips from 1 to 99; both names see it.

Figure — Lists — creation, indexing, slicing, mutability

Step 4 — The degenerate check: what if we rebind instead of edit?

WHAT. Compare two very different lines:

  • b[0] = 99 — reach through b, edit box 0 inside the list.
  • b = [99, 2, 3] — build a brand-new list and re-point b at it.

WHY this case matters. The first mutates; the second rebinds. They look almost the same but do opposite things to a. Rebinding peels b's arrow off the shared list and lands it on a fresh one — so a is left untouched.

PICTURE. Left panel: b[0]=99 (both arrows stay, shared box changes). Right panel: b=[...] (b's arrow jumps to a new list, a's list unchanged).

Figure — Lists — creation, indexing, slicing, mutability

Step 5 — Making a real copy: a[:] builds a NEW row of boxes

WHAT. Run c = a[:]. A full slice walks the whole list and pours every element into a freshly built list. Then c points at that new list — a genuinely separate object.

WHY a slice copies. A slice always returns a new list (that is its defined job). a[:] means "from start to end" — so it copies everything. Now there are two lists and two arrows landing on different targets.

PICTURE. Two separate box-rows: a's original and c's clone, arrows going to different destinations.

Figure — Lists — creation, indexing, slicing, mutability

Step 6 — The append vs + edge case

WHAT. Two ways to "add" an item behave differently for shared names:

  • a.append(0) mutates the existing list in place and returns None.
  • a = a + [0] builds a new list and rebinds a.

WHY it bites. If b = a first, then a.append(0) is visible through b (same list). But a = a + [0] peels a off onto a new list — b still sees the old one. And never write a = a.append(x): since append returns None, you'd set a = None and lose the list entirely.

PICTURE. Left: append grows the shared list (both arrows still land on it, now longer). Right: + spawns a new list for a, leaving b on the old.

Figure — Lists — creation, indexing, slicing, mutability
Recall Quick self-check

After a=[1]; b=a; a.append(2), what is b? ::: [1, 2] — same list, append is visible through b. After a=[1]; b=a; a=a+[2], what is b? ::: [1]+ rebound a to a new list; b still points at the old one. What does a = a.append(2) set a to? ::: Noneappend returns None, so the name loses the list.


The one-picture summary

Everything on this page is one question — how many arrows point at this list? — drawn as a single map: b = a adds an arrow (share); mutation edits the shared destination (visible to all); a[:]/copy/list builds a new destination (independent); +/rebind moves an arrow away.

Figure — Lists — creation, indexing, slicing, mutability
Recall Feynman: the whole walkthrough in plain words

Imagine every list is a real row of numbered lockers standing in a hall. A name like a is just a sticky note with a string running from it to that locker-row. Writing b = a doesn't build new lockers — it staples a second sticky note to the same string, so a and b are two labels for one row. Now if you open a locker through b and swap what's inside (b[0] = 99), of course a sees it too — it's the same row of lockers. The only way to get separate lockers is to actually build a new row and copy the stuff over, which is what a[:] (or .copy(), or list(a)) does. Careful with the near-twins: b[0] = 99 reaches through the string and edits the destination, but b = [99,2,3] yanks the string off and pins it to a brand-new row — so a is untouched. Same story for adding items: append makes the shared row longer (everyone sees it), while a = a + [x] quietly builds a new row and re-pins a. The single question that answers every puzzle: count the arrows landing on this list.


See also