Visual walkthrough — Lists — creation, indexing, slicing, mutability
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.

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.

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.

Step 4 — The degenerate check: what if we rebind instead of edit?
WHAT. Compare two very different lines:
b[0] = 99— reach throughb, edit box0inside the list.b = [99, 2, 3]— build a brand-new list and re-pointbat 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).

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.

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 returnsNone.a = a + [0]builds a new list and rebindsa.
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.

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? ::: None — append 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.

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
- Lists — creation, indexing, slicing, mutability (parent)
- Shallow vs Deep copy — what happens with nested lists
- List methods — append, insert, pop, sort — more in-place mutators
- Mutability & function arguments — the same sharing, across function calls
- Tuples — the immutable cousin where none of this bites
- for loops · Strings — indexing & slicing