Home › Session 03
Foundations · Session 03

Lists, Dicts & References

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

~40 minutes
5 interactive demos
Builds on Sessions 01 and 02
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 through it line by line. Keep asking whether you're looking at a new tray, or the same tray with another nametag on it. 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 the object addresses. The same address means the same object. Different names don't 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 behaviour at a time.

A list or dict is a container object

In plain words Putting 99 in a list doesn't 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, and then both of you are looking at the same tray.

A container doesn't absorb its contents. It stores references to other objects. That Python-level model is enough to predict everyday code. You don't need CPython's internal storage layout for it.

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, but 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, with the 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's safe, because nothing can change them.

The multiplication trap

In plain words [[0] * 3] * 3 doesn't 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 different things. On a list, += is an in-place mutation, while 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 labelled 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 couldn't 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 the same object. If another name, object or function parameter points there too, it will see the mutation.

What you should understand by the end

Identity

is answers "same object?"

Use identity checks while you're learning, to confirm whether two names are aliases. In normal application code, prefer == unless you're checking a singleton 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, a shallow copy for a new outer container, and a deep copy only when the nested objects really need to be independent.

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 the 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 doesn't.

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.

Common mistakes

Four ways this goes wrong

In plain words Containers hold arrows, and arrows are cheap to copy. Almost every container bug is two arrows pointing at one tray when you pictured two trays, or a method returning nothing when you expected a value.

x = y = []: one list, two names

Chained assignment builds the list once and sticks both nametags on it. x.append(1) then shows up in y, and x is y is True. Write two statements, x = [] and y = [], when you want two trays. The same trap wears a different costume in [[0] * 3] * 3, which repeats one row three times.

nums = nums.sort()

sort() sorts the list in place and returns None. It's a mutation, not a value. After that line, nums is None and the sorted list has lost its only name. The same goes for append, reverse, extend and update. When you want a new object handed back, use the function form: sorted(nums), reversed(nums).

Deleting keys from a dict while looping over it

for k in d: del d[k] raises RuntimeError: dictionary changed size during iteration. The loop is walking the dict's own slots and you are moving them underneath it. Loop over a snapshot instead, with for k in list(d):, or build a new dict with a comprehension and rebind the name.

Trusting a copy to go all the way down

a.copy(), a[:], list(a) and dict(d) all copy one level. Nested lists and dicts inside are still shared, so b[0].append(99) changes a[0] too, which is demo 2 all over again. Reach for copy.deepcopy when the nested objects really have to be yours, and only then, because it walks the whole tree.

Check
One question answers most container bugs
"Is this a new tray, or the same tray with another nametag?" Compare the addresses with id(a) == id(b), or just a is b, and the surprise usually disappears on the spot.
Check your understanding

Can you predict the memory?

The same address means the same object. That one 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.
4. x = y = [], then x.append(1). What is y?
The [] is evaluated once. Both x and y point at that single object, so a mutation through one is visible through the other. x is y is True.
5. nums = [3, 1, 2], then result = nums.sort(). What are result and nums?
In-place methods return None on purpose, so you cannot mistake them for the copying kind. sorted(nums) is the one that builds and returns a new list.
6. d = {"a": 1, "b": 2}, then for k in d: del d[k]. What happens?
The loop walks the dict's own storage, and deleting a key changes that storage under it. Iterate over a copy of the keys, for k in list(d):, or build the dict you want and rebind the name.