Skip to main content

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​

Quiz

Variables as labels

1 / 2

After 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 ==​

Quiz

Choosing is vs ==

1 / 2

The 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​

Quiz

Relative immutability

1 / 2

A 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​

Quiz

Copying containers

1 / 2

list(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​

Quiz

Parameters as aliases

1 / 2

A 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​

Quiz

Default argument pitfall

1 / 2

def 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​

Quiz

Owning your data

1 / 2

self.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​

Quiz

del and object lifetime

1 / 2

del 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​

Quiz

Interning is an implementation detail

1 / 2

tuple(t) and t[:] on an existing tuple t

Pick an answer to see if you are right.


Chapter summary​

Key takeaways
  1. Variables are labels stuck on objects, not boxes holding copies — b = a creates an alias, not a duplicate.
  2. == compares values; is compares identity. Reserve is for None checks and sentinel objects.
  3. A tuple's own slots never change, but a mutable item inside it can — so a tuple's value isn't fully frozen.
  4. list(x) / x[:] / copy.copy make shallow copies; nested mutables are still shared. Use copy.deepcopy for real independence (it survives cycles too).
  5. Arguments are passed by call-by-sharing: a function can mutate a mutable argument in place, but rebinding a parameter never reaches the caller.
  6. Never use a mutable literal as a parameter default — it's built once, at def time, and shared across every call that relies on it. Default to None and build fresh inside the function.
  7. Copy incoming mutable arguments before storing them on self, unless mutating the caller's object is explicitly the point.
  8. del removes a reference, not an object. CPython reclaims memory via reference counting the instant a refcount hits zero; a separate collector handles reference cycles.
  9. CPython sometimes reuses immutable objects (interning) to save memory. It's an optimization detail — never branch your logic on it.

Exercises​

Activity: TwilightBus fix

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.