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.
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.
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 |
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.
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.
Caller sees it
scores["math"].append(95) follows references to the original list and changes it in-place.
The caller sees the new score.
Caller does not see it
scores = {"math": []} changes only the local name scores. The caller's
book name still points to the old dict.
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.
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.
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.
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. |
What you should understand by the end
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.
Object changes, names stay bound
append, item assignment, and dict updates change existing objects. The address stays the same.
Name changes, object may not
x = ... makes a name point somewhere else. Other aliases keep pointing to the original object.
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.
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.
* 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.
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.
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.
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.
id(a) == id(b), or just a is b, and the surprise usually disappears on the spot.