Names, Objects & Identity
In short: a Python name is a label, not a box — it points at an object
somewhere in memory, and = always points a name at an object, never copies one.
Two names can point at the very same object (aliasing); mutating through
either name is visible through both, but rebinding one name (pointing it
somewhere else) never touches the other. A function call works the same way — the
parameter is a new name bound to whatever object the caller passed, so a function
can mutate the caller's object but can never rebind the caller's name. Use == to
compare values and save is for true identity questions (None, sentinels) —
never numbers or strings, where it can look right by accident.
![Two labels "raw" and "cleaned" both pointing at one list before an append; after cleaned = [...] rebinds cleaned onto a new stripped list while raw still points at the original, unchanged](/api/assets/python/assets/010bd0bfbabd2b83.png)
An audit that always says "nothing changed"
A cleaning stage trims whitespace from every field of a batch. An audit stage runs later and compares the cleaned rows against the raw ones — and always reports that nothing changed:
Nobody edited raw on purpose. cleaned = raw never copied anything — it just
gave the same list a second label, so a change through one name shows up through
the other. The fix is for the cleaner to build a new list instead of mutating
the one it was handed:
raw = ["alpha ", " beta"]
cleaned = raw
cleaned = [f.strip() for f in cleaned] # a comprehension builds a NEW list
print(raw)Step through the same audit code and watch the same column. It's True only
while both names point at one object:
Drawn out, the end state looks like this:
Until the append, both names pointed at list #1, so it changed the one list they
shared. The comprehension built list #2, and = moved one arrow onto it. That is
the whole fix for the audit bug: the cleaner should build new rows instead of
editing the rows it was handed.
Passing an argument is binding a name
A function call does exactly what = does: the parameter becomes a new name
bound to whatever object the caller passed. Nothing is copied — this is called
call by sharing, and it's neither "by reference" nor "by value":
def clean(row):
row.append("checked") # mutates the object the caller passed
row = ["fresh"] # rebinds row — a LOCAL name only
row.append("new")
row = ["a"]
clean(row)
print(row)A function that needs to hand back a changed version should return it. A function that mutates its argument should say so in its name.
The grid that repeats one row three times
* on a list repeats references, not copies. For numbers that's harmless,
because a number can't be mutated — for rows of a grid it's a trap:
Only the top-left cell should become 1. Run it, see every row change, then fix the first line so each row is its own list.
The inner [0] * 3 is safe, because it repeats references to one 0, and 0
can't change. The outer * 3 repeats references to one list, and a list can. A
comprehension evaluates [0] * 3 fresh on every loop, so each row really is a
separate object.
Shallow copy vs. deep copy
When a stage genuinely needs its own version of the data, it copies — but "copy"
has two depths. A shallow copy (list(x), x.copy(), x[:]) makes one new
outer container, filled with references to the same inner items. A deep
copy (copy.deepcopy(x)) walks the whole structure and copies every mutable
container it finds, all the way down:
That's not a naive recursive copy — a naive one would copy tags twice (breaking
the sharing) and recurse forever on the cycle. deepcopy keeps a memo, a
dict from id(original) to the copy already made for it, and checks it before
copying anything. Shallow-copy when you're only changing the top level; deep-copy
when you're about to change something nested; skip copying entirely when a stage
can just return new rows instead of editing the ones it was handed.
Quick practice
a = 256
b = 256
x = "".join(["qu", "arry"])
y = "quarry"
print(a is b, x is y)A plain default=None couldn't tell "no default given" apart from "the default
really is None". A freshly built object() can, because no caller could ever
pass in that exact object by accident — that's what is is actually for.
🎯 Your turn
The inspector's first piece replays how a stage script binds its names, and
reports which ones end up sharing an object. Each step is [target, op, source]:
[name, "new", null]bindsnameto a brand-new empty list[name, "alias", other]bindsnameto the same objectotherpoints at[name, "copy", other]bindsnameto a shallow copy of that object
Write shared_names(steps): run the steps in a dict that plays the part of a
namespace, then return every group of two or more names that end up pointing at
one object — names sorted within each group, groups sorted.
[["raw", "new", null], ["clean", "alias", "raw"], ["backup", "copy", "raw"]]→[["clean", "raw"]][["a", "new", null], ["b", "alias", "a"], ["a", "new", null]]→[], because rebindingamoved onlya
Hint: a dict keyed by id(obj) collects the names sharing each object — ids
are safe here because every object stays alive in your namespace while you're
looking at it.
Press ▶ Run, then ✓ Check. ✅
