Home Session 03
Foundations · Session 03

Lists, Dicts & References

If two variables share a list, changing one changes the other. That is not a bug — they are looking at the same list. This session makes that picture precise: containers, aliases, copies, and mutations that travel.

~40 minutes
5 interactive demos
Beginner friendly, practical depth
Learning goal

A list is a tray of arrows, not a box of values.

Each slot points at some other object. Two names can share one tray. A cheap copy makes a new tray but keeps the same arrows. Once that feels true, aliases, nested mutation, and function side effects stop being surprises.

list dict aliasing shallow copy function args identity
Recommended path
1 Track the container first. Ask which names point to the list or dict object.
2 Track the contents next. List slots and dict values are references to other objects.
3 Name the operation. Is Python mutating an object, rebinding a name, or copying only one container level?
Interactive lab

Watch the tray, then the arrows

Pick a demo and step line by line. Ask: is this a new tray, or the same tray with another nametag? Nested labels such as list -> 0x7f... mean a slot pointing at another object — not a value stuffed inside the list.

Tip: when a result surprises you, compare object addresses. Same address means same object. Different names do not guarantee different data.
1 Choose a demo
2 Step with or Play
3 Watch refcounts and mutation states
4 Read the model below
Names / frames
Container objects and nested refs
next back Space play R reset
python
1 / 1
Memory
stack heap
1

Initial state - no names, no list yet

Press Play or use Next to watch one container behavior at a time.

A list or dict is a container object

In plain words Putting 99 in a list does not swallow the number into the list. The list keeps a pointer that says “slot 0 is over there.” Another name can point at the same list — then both of you are looking at the same tray.

A container does not absorb its contents. It stores references to other objects. That Python-level model is enough to predict everyday code. You do not need CPython's internal storage layout.

nums
->
list @ 0x7f530010 [0] -> 99 [1] -> 20 [2] -> 30
same
->
same list object @ 0x7f530010

Name

A name such as nums lives in a namespace or frame. It is a label, not the list itself.

Container object

The list or dict object lives on the heap. It has identity, mutability, contents, and a refcount.

Element references

List slots and dict values point to other objects. For nested containers, those objects may be lists or dicts too.

Model
Containers hold references at the Python level
The runtime uses efficient internal storage to make lists and dictionaries fast. You do not need those low-level details to reason correctly. For everyday Python, use this model: the container has slots or key-value entries, and those entries refer to objects.
Copying

Shallow copy copies one container level

In plain words A cheap copy is like getting a new folder and putting the same papers in it. The folder is yours. Scribbling on a paper still shows up in the other folder. A deep copy photocopies the papers too.

A shallow copy creates a new outer container filled with the same inner references. The outer list or dict is independent. Nested objects are shared.

One outer list

original = [[1], [2]] creates an outer list. Its two slots point to two inner list objects.

Two outer lists, shared inner lists

shallow = original.copy() creates a second outer list, but both outer lists point to the same inner lists.

Operation New outer container? Nested objects shared? Common use
b = a No Yes, because everything is the same object Create another name for the same container
b = a.copy() Yes Yes Copy a flat list or dict
b = a[:] Yes Yes The classic slice copy — same result as list(a)
b = list(a) Yes Yes Make a shallow list copy from any iterable
b = copy.deepcopy(a) Yes No — mutable nested objects are cloned Independent nested data, used deliberately
Care
Deep copy is powerful, but not always the right default
A deep copy walks through nested objects and copies them too. That can be expensive and can be wrong for objects that should stay shared, such as open files, database connections, or intentionally shared configuration. Use it when you specifically need nested independence. Immutable nested values such as ints, strings and tuples of immutables are still shared after a deep copy — that is safe, because nothing can change them.

The multiplication trap

In plain words [[0] * 3] * 3 does not make three rows. It makes one row and then points at it three times. Write into any cell and the whole column seems to change, because there was only ever one list to write into.

List multiplication repeats references, exactly like a shallow copy. The list comprehension runs [0] * 3 once per row, so each row is a separate object.

python
grid = [[0] * 3] * 3 # three names for ONE row grid[0][0] = 1 print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] print(grid[0] is grid[1]) # True grid = [[0] * 3 for _ in range(3)] # a fresh row each time grid[0][0] = 1 print(grid) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
Functions

Passing a container passes the object reference

In plain words Hand a function your scorebook and it can write a new score on the page you already have. If it tosses that book aside and starts a blank one, your original book is still on your desk.

The parameter is a local name pointing at the same mutable object as the caller. Mutation is visible. Rebinding the parameter is not.

Mutating

Caller sees it

scores["math"].append(95) follows references to the original list and changes it in-place. The caller sees the new score.

Rebinding

Caller does not see it

scores = {"math": []} changes only the local name scores. The caller's book name still points to the old dict.

python
def add_score(scores): scores["math"].append(95) # mutates caller-visible list scores = {"math": []} # rebinds only the local name return scores

The same split shows up in += versus +

These two lines read almost the same and do genuinely different things. On a list, += is in-place mutation; a = a + [...] builds a new list and rebinds the name.

Mutating

a += [2] edits the list

Start with a = [1]; b = a. After a += [2] you get a = [1, 2] and b = [1, 2], and a is b is still True. One list, edited where it stands.

Rebinding

a = a + [2] makes a new list

Same start. After a = a + [2] you get a = [1, 2] but b = [1], and a is b is False. The old list is untouched; a simply points somewhere else now.

Dictionaries

Dict keys and values are references too

In plain words A dict is a labeled tray: each label points at some value. The value can be a list you later append to. The label itself has to stay stable — if the key could change after you filed it, Python could not find it again.

Keys must be hashable, which for built-ins usually means immutable (strings, ints, tuples of immutables). Values can be anything, including mutable lists.

Question Plain answer Why it matters
Can two names point to one dict? Yes. alias = profile creates another reference. Mutating through either name changes one shared dict.
Can a dict value be a list? Yes. Values can be mutable objects. profile["skills"].append(...) mutates the nested list.
Can a list be a dict key? No. Lists are mutable and unhashable. If a key could change after insertion, lookup would become unreliable.
Does dict.copy() copy nested values? No. It is shallow. Nested lists and dicts remain shared unless you copy them deliberately.
Key
The practical rule
Before changing a list or dict, ask: "Who else has a reference to this same object?" If another name, object, or function parameter points there too, it will observe the mutation.

What you should understand by the end

Identity

is answers "same object?"

Use identity checks while learning to confirm whether two names are aliases. In normal app code, prefer == unless checking singletons like None.

Mutation

Object changes, names stay bound

append, item assignment, and dict updates change existing objects. The address stays the same.

Rebinding

Name changes, object may not

x = ... makes a name point somewhere else. Other aliases keep pointing to the original object.

Copying

Choose the copy depth intentionally

Use assignment for sharing, shallow copy for a new outer container, and deep copy only when nested independence is truly required.

Hashability

A key's hash has to hold still

{[1, 2]: "x"} raises TypeError: unhashable type: 'list'. Swap the list for a tuple and {(1, 2): "x"}[(1, 2)] returns "x" — matching on hash and ==, not on is.

Repetition

* 3 repeats the arrow, not the object

[[0] * 3] * 3 gives one row referenced three times, so grid[0] is grid[1] is True. Build rows with a comprehension when you want three separate lists.

Try this in a REPL

Paste this whole line: import copy; a = [[1]]; b = a.copy(); b[0].append(2); print(a) — it prints [[1, 2]], because a and b share the inner list. Now, in that same session, run c = copy.deepcopy(a); c[0].append(3); print(a, c). The 3 lands only in c. Shallow shares the inner list; deep does not.

What you should be able to draw

A tray of arrows, not a box of values. Two nametags on one tray. A cheap copy as a new tray holding the same inner arrows.

Check your understanding

Can you predict the memory?

Same address means same object. That rule solves most container bugs.

1. After b = a.copy() on a nested list, what is shared?
Shallow copy fills the new container with the same references. Use deepcopy when nested independence is required.
2. Why can a list not be a dict key?
If a key could change after insertion, the dict could not find it again. Tuples of immutables are allowed. Identity is not required: two equal tuples work as one key even though they are different objects, because lookup goes by hash and ==.
3. You pass a dict into a function that does scores["math"].append(95) and then scores = {}. What does the caller see?
The parameter starts as an alias. append mutates the nested list. scores = {} only rebinds the local name.