Object References, Mutability, and Recycling
Source: Fluent Python, 2nd Edition — Chapter 6 by Luciano Ramalho
Every Python object has an identity, a type, and a value. Only the value can change over time. This chapter is about the gap between a variable and the object it points to — and the bugs that live in that gap: aliasing, mutable default arguments, shallow copies, and functions that mutate what you handed them.
b = a does not copy a. It sticks a second label on the same object. Everything else in this chapter follows from that one fact.
Variables are labels, not boxes
Think "box" and b = a should copy the contents of a's box into b's box. Python doesn't work that way. Think "sticky note" instead: a and b are two notes stuck on the same object.
🧪 One list, two labels
Read an assignment right side first: that's where the object gets created or looked up. Then the name on the left gets bound to it, like a label. If the right side raises before finishing, the left name is never bound at all — there is no half-built object sitting in a box waiting for a name.
Mini quiz — labels, not boxes
Variables as labels
1 / 2After b = a on two lists, appending to a
Pick an answer to see if you are right.
Challenge: same list, two names
Read n1 n2 n3 extra. Build a from the first numbers, alias b = a, then append extra through b. Print a — the append must be visible there too.
Identity, equality, and aliases
== compares values. is compares identity (id(), the object's address in CPython). Two variables can hold equal-but-distinct objects — same data, different identity.
🧪 Aliases vs. lookalikes
lewis is an alias for charles: same object, so mutating through one shows up through the other. alex is a separate dict that happens to compare equal — a lookalike, not an alias.
By far the most common is check is against None:
if x is None:
...
is is also right for sentinel objects — a private marker object used only to detect "argument not given":
MISSING = object()
def describe(x=MISSING):
return "missing" if x is MISSING else str(x)
is is faster than == because it can't be overloaded — it's just an integer comparison, no __eq__ call. But that's a side benefit, not the reason to reach for it. Use == for values; save is for identity questions: "is this the same object," "is this None," "is this my sentinel."
Mini quiz — is vs ==
Choosing is vs ==
1 / 2The recommended way to check for None is
Pick an answer to see if you are right.
Challenge: update through alias
Build record = {'name': name}, alias person = record, then set person['balance']. Print record['balance'] — the mutation through the alias must be visible on record.
The relative immutability of tuples
A tuple is immutable — but only its own slots are. It can't be resized and its items can't be swapped for other objects. If one of those items is itself mutable (a list, say), that item can still change, and the tuple's value changes with it, even though its identity and its references never do.
🧪 A tuple whose value changes
The identity of t1[-1] never changes — it's the same list object before and after. What changed is that list's contents, and that's enough to make t1 != t2. This is also why a tuple containing a list is unhashable: hashing needs a value that can't shift under it.
Mini quiz — tuples and mutable items
Relative immutability
1 / 2A tuple (1, [2, 3]) is immutable. Which is true?
Pick an answer to see if you are right.
Challenge: tuple with a mutable item
Build t = (1, 2, list(nums)). Append extra to the list inside the tuple — not the tuple itself. Return that inner list.
Copies are shallow by default
list(l1) and l1[:] both copy the outer container only — the copy's slots point at the exact same inner objects as the original. That's a shallow copy. Cheap, and fine when every item is immutable. Risky the moment an item is mutable.
🧪 A shallow copy shares its inner list
l1.append(100) only touched l1's own slots — l2 doesn't see it. But l1[1].remove(55) mutated the inner list, and l2[1] is the same inner list object, so l2 sees that change too.
For a real independent copy — including nested mutable items — use copy.deepcopy. It's smarter than a naive recursive copy: it remembers objects it has already copied, so cyclic references (an object that (indirectly) contains itself) don't cause infinite recursion.
Mini quiz — shallow vs deep
Copying containers
1 / 2list(l1) and l1[:] both produce
Pick an answer to see if you are right.
Challenge: deep copy a passenger list
Clone names with copy.deepcopy before removing to_drop from the original. The clone must be unaffected by that removal.
Function parameters are references
Python passes arguments by call by sharing: each parameter becomes a new alias for the same object the caller passed. A function can mutate a mutable argument, but it can never make the caller's variable point somewhere else — rebinding a parameter inside the function only rebinds the local name.
🧪 += reacts differently depending on the type
For the ints, a += b rebinds the local a to a new int; x never sees it. For the lists, a += b calls list.__iadd__, which extends in place and rebinds a to that same, now-mutated, object — so lst_a shows the change too.
Mini quiz — call by sharing
Parameters as aliases
1 / 2A function does items.append(x) where items is a list parameter. After the call, the caller's list
Pick an answer to see if you are right.
Challenge: mutate the caller's list
Use items.extend(more) — in-place mutation, not items = items + more, which would only rebind the local name. The caller must see the change without using your return value.
Mutable defaults are a bad idea
A default value is evaluated once, when the def runs — not once per call. It becomes an attribute of the function object itself. If that default is mutable and a method mutates it in place, every future call that relies on the default shares the same damaged object.
🧪 The haunted bus
bus3 never picked anyone up, yet it starts with 'Carrie' already aboard — because both instances share the exact same default list. The fix: default to None, and build a fresh list inside __init__ when nothing was passed.
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers)
Mini quiz — mutable defaults
Default argument pitfall
1 / 2def f(items=[]): default values are evaluated
Pick an answer to see if you are right.
Challenge: fix the haunted bus
Bus(passengers=[]) shares one list across every instance that skips the argument. Fix __init__ so each Bus gets its own passenger list.
Defensive programming with mutable parameters
Aliasing bites the other way too: if __init__ stores the argument object directly, later mutations by the object leak back and change the caller's data — surprising and rarely wanted.
🧪 Passengers vanish from the caller's list
team shrank just because a TwilightBus dropped a passenger — that violates the principle of least astonishment. The fix is the same move as HauntedBus's: don't alias what you were handed, copy it.
self.passengers = list(passengers) if passengers is not None else []
Unless a method's whole job is to mutate what it received, think twice before storing an incoming mutable object directly on self. When in doubt, copy — a slightly slower API beats one with subtle action-at-a-distance bugs.
Mini quiz — defensive copying
Owning your data
1 / 2self.passengers = passengers (no copy) in __init__ means
Pick an answer to see if you are right.
del and garbage collection
del is a statement, not a function — del x removes the name x from the current scope. It does not destroy the object. An object is only reclaimed once its reference count reaches zero, whether that happens via del or by rebinding the last name pointing to it.
🧪 Watching an object actually go away
del s1 only removes the s1 reference — s2 still points at the set, so ender.alive stays True. Only when s2 is rebound does the refcount hit zero, the set gets collected, bye() fires, and ender.alive flips to False. Note that bye must not hold a reference to the object it's watching (e.g. never a bound method of it) — that would keep the refcount above zero forever.
CPython's primary mechanism is reference counting: as soon as an object's refcount hits zero it's destroyed immediately, __del__ (if defined) runs, and the memory is freed. A separate generational collector also handles reference cycles — groups of objects that reference each other but are unreachable from anywhere else. You'll rarely need to write __del__ yourself; it's easy to misuse and other Python implementations don't guarantee it runs promptly.
Mini quiz — del and refcounting
del and object lifetime
1 / 2del x
Pick an answer to see if you are right.
Tricks Python plays with immutables
For immutable types, CPython sometimes hands back the same object instead of building a new one — tuple(t) and t[:] on an existing tuple t both just return t itself, no copy at all. str, bytes, and frozenset (via .copy()) behave the same way.
🧪 Sometimes "new" isn't new
The first line reliably prints True True — tuple(t1) and t1[:] are guaranteed no-op copies for an already-immutable tuple. The second line is where it gets interesting: a and b are built from two separate literals, yet depending on the interpreter and its optimizer, a is b can come back True (small integer tuples and short strings are common interning candidates) or False. This is an implementation detail, not a language guarantee.
Never depend on it. Always compare str and int values with ==, never is. Interning exists purely so the interpreter can save memory and time internally — it was never meant to be part of your program's logic.
Mini quiz — interning
Interning is an implementation detail
1 / 2tuple(t) and t[:] on an existing tuple t
Pick an answer to see if you are right.
Chapter summary
- Variables are labels stuck on objects, not boxes holding copies —
b = acreates an alias, not a duplicate. ==compares values;iscompares identity. ReserveisforNonechecks and sentinel objects.- A tuple's own slots never change, but a mutable item inside it can — so a tuple's value isn't fully frozen.
list(x)/x[:]/copy.copymake shallow copies; nested mutables are still shared. Usecopy.deepcopyfor real independence (it survives cycles too).- Arguments are passed by call-by-sharing: a function can mutate a mutable argument in place, but rebinding a parameter never reaches the caller.
- Never use a mutable literal as a parameter default — it's built once, at
deftime, and shared across every call that relies on it. Default toNoneand build fresh inside the function. - Copy incoming mutable arguments before storing them on
self, unless mutating the caller's object is explicitly the point. delremoves a reference, not an object. CPython reclaims memory via reference counting the instant a refcount hits zero; a separate collector handles reference cycles.- CPython sometimes reuses immutable objects (interning) to save memory. It's an optimization detail — never branch your logic on it.
Exercises
TwilightBus.__init__ currently aliases the caller's list, so .drop() mutates the caller's data. Fix it to copy with list(passengers) instead. Read names on one line, drop the first name, and print the original list — it must be untouched.