python/volume-1/chapter-03ch 03 / 64 · 23 min

03Names & memory

In Chapter 2 we learned that every value is an object — a thing that stands on its own in memory, carrying its own type and its own moves. This chapter asks the other half of the question: how does a name find that object? Most courses answer with a box that holds a value. That picture is wrong, and it's behind nearly every surprise Python has in store. Here's the plan. First we'll watch a name land on an object like a sticky note. Then we'll slap a second note on the same object and see what happens when one of them scribbles on it. We'll separate the values you can edit in place from the ones you can't. And we'll open up Python's memory manager while it runs a quiet label census, hands out small integers from a shelf it built before you woke up, and hits the one blind spot where that census fools itself — reference cycles. The whole way through, one question stays in front of us: when I change b, does a change too, and could I have known before I ran the line? By the end you'll answer that on sight, and you'll know exactly what the machine is doing to memory underneath. Queue Blinding Lights into Titanium; it's a seven-and-a-half-minute job, and operators come next.

A name is a sticky note, not a box Two labels, one object: aliasing Whiteboards and photos: mutable vs immutable Reference counting: the label census
scroll to begin

01A name is a sticky note, not a box

Let's start with the simplest line in Python and refuse to be fooled by it. Type seconds = 200 — the running time of "Blinding Lights" — and hit Enter. It reads like an order to a stock-room clerk: put 200 into a box labelled seconds. But what actually happens is nothing like that. Read the line the way the interpreter runs it: right to left, in three separate moves. First, an int object holding 200 comes into being somewhere in memory, with no name attached to it. Second, the name seconds is written into a little table of names Python keeps for you — the namespace. Third, the = copies nothing into nothing. It binds the name to the object, the way you press a sticky note onto a jar already standing on the shelf. So the name and the object are two separate things that met a moment ago — and id(seconds) hands you the exact spot where they met.

Because a sticky note is only a note, it peels off clean. Write seconds = "3:20" and the very same name lifts off the int and lands on a brand-new str object — a different thing, at a different address, of a different type. Now look at what never changed type: the name. Names don't carry types; objects do. seconds was never "an integer variable" that later turned traitor. It was always just a label, and a label will stick to anything. That one fact is the whole of what people mean by dynamic typing: labels are typeless, retargetable, and very nearly free.

THE ONE IDEA TO CARRY FORWARD
A name is not a container — it is a binding: one entry in a table, pointing at an object that exists on its own. The object owns the value and the type; the name owns nothing but a direction to look. Every strange thing names do in the rest of this chapter grows straight out of that.
The box story is wrong; the label story is realfigure
✗ the box story (wrong) seconds 200 "the value lives inside the name" ✓ the label story (real) seconds int · 200 0x7ff9…5298 the note can move; the object stands on its own
Fig 01 — A variable does not hold a value. It holds a direction to an object that lives elsewhere, on its own address — which is why the same name can later point somewhere completely different.
↺ the thing people get backwards
Most of us picture a variable as a box that contains a value. It's really a note stuck onto an object that already exists. Boxes hold one thing each, and copying a box duplicates whatever's inside. Notes merely point — and nothing on earth stops you sticking twenty notes on one jar. Keep that difference close; nearly every "surprise" about Python names is really this box-vs-note mix-up coming back to bite.

You don't have to take the three-step story on faith — you can walk through it. Drag the control below through the four beats of seconds = 200, with a rebind at the end, and watch the order for yourself. The object shows up before the name exists. The note reaches across only when = fires. And a rebind swings that note to a different object entirely, leaving the old one with nothing pointing at it.

Step through seconds = 200, then a rebindinteractive
① An int object holding 200 comes to exist — nameless. namespace — a table of names seconds heap — where objects live int · 200 0x7ff9…5298 str · "3:20" 0x1d80…e510
1 / 4
Four beats: build the object, write the name, let = land the note, then rebind. The int is never found and the note is never copied — it simply points, then points elsewhere.
Fig 02 — The object exists first and namelessly; the name is a later, separate act; = is the moment they connect. Rebinding moves the note, not the object — and orphans whatever the note just left.

Now watch real Python agree with every frame of that figure. id() reports where the object lives, type() asks the object what it is, and sys.getsizeof() weighs it. After the rebind, all three answers change — because you are pointing at a different object, while the name itself stayed exactly what it always was: a label.

names.pypython
seconds = 200                # "Blinding Lights", 200 s
print(id(seconds))           # 140711682527896  — an address (yours differs)
print(type(seconds))         # <class 'int'>   — the TYPE lives on the object
import sys
print(sys.getsizeof(seconds)) # 28  — the object's weight, in bytes

seconds = "3:20"             # peel the note off the int, press it onto a str
print(id(seconds))           # 2026991836240  — a different address
print(type(seconds))         # <class 'str'>  — the name never had a type of its own
Wait — if seconds = 200 builds the object first and names it second, what was the object called during that first instant? Nothing. Objects don't need names to exist — a name is a convenience for you, not a requirement for the object. Type id(245) at a prompt and Python conjures an int for "Titanium", reports its address, and lets it evaporate on the next line: it lived, fully real and completely nameless, for a few microseconds.
THE NAMESPACE IS LITERALLY A DICT
That "table of names" isn't a metaphor Python keeps hidden — it hands it to you. At the top level, globals() is a plain dict, and after you run seconds = 200 the test 'seconds' in globals() answers True. Binding a name is a dictionary insert; rebinding is an update; del seconds is a delete that removes the key while leaving the object alone. Names live in a dict — that's not an analogy, it's the implementation.
WHY MOVING A NOTE IS CHEAP
Rebinding never copies the object. The name slot holds one machine pointer — 8 bytes on a 64-bit build — so retargeting seconds a million times just overwrites those 8 bytes a million times; not a single 200 is ever duplicated. The object carries its 28 bytes of weight; the name carries only an address. That gap is exactly why the next section — two names on one object — costs almost nothing.

✗ The myth

seconds = 200 stores the number 200 inside the variable seconds, the way a jar holds jam. The value and the name are one thing.

✓ The reality

It stores the address of an int object that lives elsewhere in memory. The variable holds a direction, not a value — and the object it points to would exist, and keep its type, even if the name vanished.

The deeper cut — in what sense id() is "the address"

Saying "id(seconds) is the object's memory address" is a labelled simplification. It's dead accurate on the Python you're running, so we lean on it, but it's worth refining exactly once. On CPython (the interpreter you almost certainly have), id(x) returns the raw address of the object in memory. You can prove it the brutal way: take that integer, hand it to ctypes.cast(addr, ctypes.py_object).value, and Python reads 200 straight back out of that location. So on this machine, the meeting-point really is a street address.

But the language promises less than that. The spec only guarantees that id() returns an integer that is unique among currently-living objects and constant for one object's lifetime. It says nothing about that integer being an address. A different implementation (PyPy, Jython) is free to hand you an opaque ticket number that is not a location at all. So trust id() for the question it truly answers — "same object or not?", the whole basis of the next section — and treat "it's the address" as a CPython bonus, not a promise. And once the last note peels off an object and nothing points at it, as the int 200 does in step ④, CPython reclaims that memory on the spot by counting references — a mechanism we'll open up later in the chapter.

If a name is just a note pointing at an object, nothing stops you slapping a second note on the very same object. Two labels, one thing — and the moment they coexist, a question sharpens: when does that shared pointing help you, and when does it quietly bite? →

Wait — id() is famous for being unique — but only among the living. Delete an object and build a new one, and the fresh object often lands at the exact same id the dead one had: CPython freed the first the instant its last name let go, and handed the vacated address straight back. Identity is a house number, not a fingerprint — reused the moment the house stands empty.
A NAME NEVER GROWS
The name slot holds one machine pointer — 8 bytes — and that never changes with what it points at. Bind a name to the tiny int 200 or to a two-gigabyte video object and the note weighs the same 8 bytes either way. The jar can be enormous; the sticky note is always the same scrap of paper.
The note stays 8 bytes; the jar weighs anythinginteractive
bind a second name — b = a. what does the note cost, and what does the jar it aims at weigh? THE NOTE · the name b 8 bytes flat · never grows · b = a copies just this THE JAR · the object it aims at int · 200 28 bytes ≈ one integer, CPU-sized ONE 8-BYTE NOTE POINTS AT AN OBJECT THIS MANY TIMES HEAVIER 3.5× its own weight — and = never copies it. b = a writes 8 bytes. A real, independent copy would haul 28 bytes — 3.5× as much. Same note either way. b = a is one pointer write. The object never moves, never copies — that's the whole trick.
int · 200
Drag from the int 200 up to a 2 GB video. The note — the name b — never budges from 8 bytes; the jar it aims at swells past two billion. That gap is exactly why b = a is instant on any object: = copies the note, never the jar. (Object weights are real sys.getsizeof figures on 64-bit CPython 3.12; order-of-magnitude for the big two.)
Fig — The note (a name) is a fixed 8-byte pointer; the jar (the object) it aims at ranges from 28 bytes to gigabytes. This is why = and b = a stay instant on any object — they copy the note, never the jar.
↺ the name never learns a type
Send one name on a tour — seconds = 200, then seconds = "3:20", then seconds = ["Blinding Lights"] — and ask what changed about the name. Nothing. It was a raw 8-byte pointer before each line and a raw 8-byte pointer after. type() never asks the name; it asks the object the name is currently aimed at. That is the whole of dynamic typing: types live on objects, and a name is too dumb to carry one.
TWO PLACES, NOT ONE
The name seconds and the int 200 don't even live in the same neighbourhood. The name is a key in the namespace dict that belongs to your module or frame; the object sits out on the heap. Rebinding rewrites the dict entry and never touches the heap object; del erases the dict key and leaves the object standing. That gap is why "deleting a name" and "an object dying" are two different events — and the whole rest of the chapter lives inside it.
Trace3 step the machine — idea · code · memory move together
Bind · rebind · orphan — the three moves inside seconds = 200
One = is really three separate acts: the object is built first and nameless, the name is written into the namespace second, and only then does the arrow connect them. A rebind swings that arrow to a new object, leaving the old one unreferenced. Press Next and watch the object appear before its name.
3 objects1 dictbuild → name → bind
The wiring — namespace ledger · heap objectsdict + heapbuild
namespacea dict · name → object
heapobjects · type · value
In plain words
Under the hood
BUILDALU · the operation
Program · names.py
namespace · type
Memory · registers + objects
registers — the interpreter state
heap · objects type · value · name-count
what's happening
beat 1 / 1

02Two labels, one object: aliasing

Here is a fact that trips up nearly everyone at least once, and it hides inside the plainest line of code you can write. An alias is a second name for the same object, and making one takes no special syntax at all. Plain assignment does it. b = a copies nothing. It does not build a second list, duplicate a single byte, or reserve one new box in RAM. It writes a second sticky note and slaps it on whatever a is already pointing at. Give your playlist two names, edit it through either one, and both names "see" the change — because there was only ever one list to begin with.

That surprises people because = looks like copying. In everyday speech, "let b be a" sounds like handing someone a photocopy. But the equals sign in Python never copies a value; it only ever aims a name at an object. So after b = a you don't have two playlists that happen to match. You have one playlist wearing two labels, and a change under either label is the same change.

THE ONE IDEA TO CARRY FORWARD
= never copies an object. It binds a name to one. So b = a gives you two names, one object — and anything you do to that object through b is visible through a, because they are the same thing.
Two names, one list — b = a copies the label, not the listfigure
a b list object 0 → 'Blinding Lights' 1 → 'Titanium' id 0x1f…a00 (one address) one object, one address · two sticky notes stuck to it · id(a) == id(b)
Fig 02 — b = a duplicates the label, never the list. The single gold box is the only object in play; a and b are just two arrows into it.

There are two questions you can ask about any pair of names, and Python answers them with two different operators. == asks "do these hold equal contents?" — a reading of the values. is asks the sharper question: "are these the very same object?" Aliasing is precisely the case where a is b is True. Two lists typed out separately can be equal without being identical — same songs, but different objects. An alias, though, is identical: one object with two arrows. And id() makes it concrete. It returns the object's identity, which in CPython is literally its address in RAM — the house number from chapter 0. Matching addresses mean there was only ever one house.

Step through the script — watch when an edit reaches ainteractive
a = ["Blinding Lights", "Titanium"] a b b is not defined yet a is born: one name, one fresh list object. list object 0 → 'Blinding Lights' 1 → 'Titanium' id 0x1f…a00 list object · the copy id 0x3c…f80 (different address)
1 / 5
Follow the arrows. While b's arrow lands on the same box as a, an edit through b reaches a. The moment .copy() swings b onto its own box, a becomes untouchable. (Addresses shown are illustrative — they differ every run.)
Fig 02b — Two acts hide in these five lines. Mutation (append) edits the object an arrow points at; every name on that object sees it. Rebinding (b = a.copy()) just moves one arrow, and leaves every other name alone.

✗ The myth

b = a copies the list, so b is a safe backup of a. Edit one, the other stays put.

✓ The reality

b = a writes a second sticky note on the same list. To get a real, independent backup you must build a new object: b = a.copy().

Watch Python confirm every frame of that figure. Read it top to bottom and notice the single line where a "changes" without you ever naming a:

alias.pypython
a = ["Blinding Lights", "Titanium"]
b = a                          # a second label, NOT a second list
print(a is b)                  # True  : the very same object
print(id(a) == id(b))          # True  : one address in RAM

b.append("Levels")             # edit the object THROUGH b...
print(a)                       # ['Blinding Lights', 'Titanium', 'Levels']  -- a "changed" too

c = a.copy()                   # NOW a genuinely separate list
print(a is c)                  # False : different object
c.append("Wake Me Up")         # edits c alone
print(a)                       # ['Blinding Lights', 'Titanium', 'Levels']  -- a is safe

And notice the mirror-image act. Where b.append(...) mutates the shared object, a plain b = ["Levels", "Wake Me Up"] would rebind. It peels b's sticky note off and slaps it on a brand-new list, and a couldn't care less. It's the same equals sign, the same "moving a label" you saw in the last section. The only reason it feels different here is that this time another name was still attached to the object you left behind.

The classic "Python changed my data" bug
It is almost never a bug in Python. It is two sticky notes on one list: a single object handed around your program under different names, edited in one place, "mysteriously" different in another. Suspect it? Print a is b (or id() of each) — a True means there was only ever one object, and your "copy" was an alias all along.
Wait — does this only bite with =? No — and this is where it stings most. Handing a list to a function makes the parameter just another sticky note on your list. So def add_song(pl): pl.append("Levels") called as add_song(playlist) mutates the caller's playlist for real; there was no copy at the door. Passing an object is aliasing, which is exactly why a function can quietly reshape data you thought you'd merely lent it.
↻ = never copies. It only ever aims a name.
Stop reading b = a as "make b like a." Read it as "point b at whatever a is pointing at." Once the equals sign means aiming an arrow instead of duplicating a thing, aliasing stops being a trap and becomes obvious: of course both arrows hit the same box — you only ever moved arrows, never built boxes.
The deeper cut — shallow copies, and the += that fools everyone

Two refinements the tidy story above quietly simplifies. First, a.copy() is a shallow copy: it builds one new outer list, but the items inside are copied as arrows, not as fresh objects. So if a playlist holds other lists — say a = [["Blinding Lights", 200]] — then b = a.copy() gives you a new outer list whose single slot still aliases the same inner list. Run b[0].append("x") and a[0] changes too. To sever every layer you need copy.deepcopy(a), which we unpack in chapter 6.

Second, a genuinely sneaky one. For a list, b += ["Levels"] and b = b + ["Levels"] look like twins but are opposites. += mutates in place: it's the same object, so any alias sees the new song, and a is b stays True. But b + ["Levels"] builds a brand-new list, and b = rebinds b onto it, leaving a on the old one — so a is b becomes False. One character of difference decides whether your neighbour's playlist changes with yours.

b could reach back and change a only because a list will hand you the pencil. Try the very same edit on the number 200 or the text of a title and the object shrugs you off — some things simply refuse to be rewritten. Next: whiteboards you can scribble on, and photographs you cannot — mutable vs immutable →

THE #1 ALIASING TRAP: A DEFAULT THAT REMEMBERS
Write def add_song(title, playlist=[]) and Python builds that default list once, when the function is defined — not once per call. Every call that omits playlist gets a second sticky note on the same list, so songs bleed from one call into the next. It's aliasing hiding in a function signature. The fix is the tell: default to None, then build a fresh list inside.
↺ swapping moves two arrows, not two lists
The famous a, b = b, a looks like it heaves two whole objects past each other. It doesn't. It swaps the two 8-byte arrows and leaves both objects exactly where they sat. Swap two million-song playlists this way and not a single title moves in memory — you rerouted two pointers and the lists never noticed. Once = means "aim an arrow," even the swap trick stops being magic.
Wait — a = b = [] does not make two empty lists. It makes one and aims both names at it — an alias born on the very first line, before you've done a thing. So a.append("Titanium") and then printing b shows ['Titanium']. The right-hand side is evaluated once; the two names are just two notes slapped on that single object.
Trace3 step the machine — idea · code · memory move together
Two labels, one list — the exact line an edit through b reaches a
Stepping these five lines pinpoints the moment aliasing begins (b = a copies the arrow, not the list) and the moment it ends (b = a.copy() builds a second list) — so you can see exactly why b.append reaches a before the copy and never after it.
5 lineslist · mutableb=a vs a.copy()
Two names, one (then two) list cardslist · mutablebuild
In plain words
Under the hood
BUILDALU · the operation
Program · alias.py
names · type
Memory · list objects + names
registers — the running state
heap · list objects each card = one object, with the names pointing at it
what's happening
beat 1 / 1

03Whiteboards and photos: mutable vs immutable

Every object Python ever builds has one of two temperaments, and once you can tell them apart, half the surprises in the language stop being surprises. Some objects are whiteboards: you can walk up and rewrite them in place, and they stay the same board. Others are photos: fixed the instant they're taken, and editable only by shooting a new one. That single split — can this object be changed from the inside, or not? — is what the words mutable and immutable mean.

The membership list is short enough to memorise. The whiteboards are list, dict, set, and bytearray — the containers you build up and tear down as a program runs. The photos are int, float, str, tuple, bytes, and frozenset — the values you quote, compare, and pass around but never scribble on. The language even ships twins: bytes is the photo and bytearray the whiteboard of the exact same raw bytes; frozenset is the photo and set the whiteboard. Same data, opposite temperament.

Here's where it bites. Take our playlist. When you write title += " (Live)" it looks like you edited the string. But strings are photos, and you cannot edit a photo. So Python does the only thing it can: it builds a brand-new str object holding "Levels (Live)" and quietly moves your title label onto it. The old photo "Levels" isn't touched — it just sits there, un-pointed-at, waiting to be swept up. Now contrast a list's append, which genuinely rewrites the whiteboard: same object, same id, one row longer. The tell is exact. Mutate a whiteboard and its id is unchanged. "Change" a photo and you're suddenly holding an object with a different id, because it's a different object.

Rewrite the board, or shoot a new photofigure
WHITEBOARD · mutable list · dict · set · bytearray list @ 0x…a0 'Blinding Lights' 'Titanium' + 'Wake Me Up' ← append id 0x…a0 · unchanged the edit lands inside the object — the label never has to move. PHOTO · immutable int · float · str · tuple · bytes · frozenset str 'Levels' the old photo — abandoned str 'Levels (Live)' title id 0x…f0 · a NEW object you can't scribble on a photo, so "changing" one means shooting a replacement.
Fig 1 — Same id after a change means you mutated a whiteboard. A new id means Python built a replacement photo and slid your label onto it, leaving the original stranded.
THE ONE-LINE LIE DETECTOR
Suspect an object of only pretending to change? Print id() before and after. Same id → you mutated a whiteboard, in place. New id → you were holding a photo, and Python quietly handed you a different object. This one habit will explain nine out of ten "but I changed it!" bugs you'll ever hit.
temperament.pypython
# a photo: the label moves, the object is new
title = "Levels"
print(id(title))          # 0x16c63363780
title += " (Live)"          # can't edit a str — Python builds a NEW one
print(id(title))          # 0x16c6338be30  ← different: the label jumped

# a whiteboard: the object stays, the contents change
queue = ["Blinding Lights"]
print(id(queue))          # 0x16c630718c0
queue.append("Titanium")     # edits the board in place
print(id(queue))          # 0x16c630718c0  ← same: the label never moved

And here's the twist that catches everyone: the very same operator behaves oppositely on the two kinds. queue += ["Wake Me Up"] extends the list in place — same id, because a whiteboard can absorb the change. But title += " (Live)" has nowhere to land the change, so it rebinds title to a freshly built string. The syntax is identical, yet one mutates while the other replaces. The difference lives entirely in the object's temperament, never in the +=.

Now the memory trade-off snaps into focus, and it's a real one you'll feel. Photos cost a fresh allocation on every "change". Build a long string with += in a loop and you've quietly minted one throwaway object per step, each abandoned the instant the next is born. That's exactly why seasoned Python reaches for "".join(parts) instead. But photos come with a payoff: because nothing can ever alter one out from under you, they are perfectly safe to share. Whiteboards are the mirror image — edits are cheap and in place, but every label stuck to the board feels every stroke. And that, at last, explains the whole aliasing surprise from the last section: aliasing only bites on whiteboards. Two names on one list means one board and two hands that can write on it. Two names on one string means two people holding copies of a photo that can never change.

↻ reframe
Mutable versus immutable isn't about whether the value can change — it's about where the change is allowed to land. On a whiteboard it lands in the object, so every label pointed there sees it. On a photo it can't land at all, so Python makes a new object and lands your label on it instead — leaving every other label on the untouched original. That one sentence is the entire aliasing story, mutation, and "surprise" all at once.

There's a second, quieter payoff to being a photo, and it's the reason two of Python's most-used containers even work. Because an immutable value is frozen for life, Python can take a permanent fingerprint of it — a hash — and trust that fingerprint forever. That fingerprint is the admission ticket to being a dict key or a set member. A whiteboard has no such stable fingerprint: mutate it after filing it away and every lookup would break, so Python simply refuses. Try it and you get TypeError: unhashable type: 'list'. This is why song titles (strings) make fine dict keys, and a (200, 245) tuple slots happily into a set, but a bare list never can.

why_keys_are_photos.pypython
# photos carry a permanent fingerprint — so they can be keys / members
lengths = {"Blinding Lights": 200, "Titanium": 245}   # str keys: fine
seen = { (200, 245) }                                # tuple in a set: fine

{ ["Blinding Lights"] }        # whiteboard as a member?
# TypeError: unhashable type: 'list'  — a board can't be fingerprinted
Build the same playlist two ways — step the sliderinteractive
WHITEBOARD · list queue list @ 0x…c0 (empty — nothing appended yet) 'Blinding Lights' 'Titanium' 'Wake Me Up' queue id never moves PHOTO · int total (running sum of seconds) int @ 0x…d0 0 total abandoned photos pile up — each += left one behind 0 ✝ freed 200 ✝ freed 445 ✝ freed Step 0 · the empty list and the int 0 both exist, untouched. (addresses illustrative — the pattern, not the exact hex, is the point)
step 0 · queue id SAME · total id NEW ×0
Both sides grow the identical playlist. The list queue keeps one id forever — it's the same board, just fuller. The int total gets a new id at every step, and its old values pile up as abandoned photos on the right. Slide to 3 and count the objects each side left behind.
Fig 2 — One whiteboard mutated three times vs. four separate photos, three of them thrown away. Same visible result, wildly different bookkeeping underneath — and it's that pile of abandoned photos the next section has to clean up.
Wait — if a string can't be edited, how does title += " (Live)" work at all — and isn't rebuilding the whole thing every time wasteful? It works because += isn't editing the old object; it's building a new string and repointing the name. And yes, it's genuinely wasteful in a loop: gluing a thousand pieces together with += mints a thousand throwaway strings, each abandoned the moment the next appears. That's the whole reason "".join(parts) exists — it builds the result once, on a single whiteboard, instead of shooting a thousand photos.

✗ The myth

"Immutable means the value can never change." So people assume a tuple is a rock — nothing inside it can ever move.

✓ The reality

Immutable means the object's own slots are frozen — you can't swap what each slot points at. But a slot can point at a whiteboard, and that can still be edited. Immutability is shallow: it locks the frame, not the world inside the picture.

The deeper cut — immutability is only skin-deep, and that's why some tuples can't be keys

Here's the subtlety the tuple hides. A tuple is a photo of which objects sit in which slots — nothing more. If a slot holds a list, the photo captures only that this slot points at that particular list; it says nothing about the list's contents. So pair = (["Blinding Lights"], 200) is a bona-fide immutable tuple, yet pair[0].append("Titanium") succeeds and mutates the list inside. The tuple's id never changes, because the slot still points at the same list — only the list's own contents moved. What you cannot do is pair[0] = [...]. Reassigning a slot is editing the tuple itself, and that raises TypeError.

This is also why "tuples are hashable" comes with an asterisk. A tuple computes its fingerprint by asking each element for its fingerprint. When the elements are all photos — (200, 245) — they give a stable hash, so the tuple is a fine dict key. But (["Blinding Lights"], 200) contains a whiteboard with no stable fingerprint, so hashing it raises unhashable type: 'list'. A tuple is hashable exactly when everything it holds is a photo, all the way down.

THE SAME BYTES, TWO TEMPERAMENTS
Remember byte 0b11001000 — 200, the length of "Blinding Lights"? Wrap it as bytes([200]) and it's a photo: data[0] = 245 raises TypeError. Wrap it as bytearray([200]) and it's a whiteboard: data[0] = 245 just works, same object, same id. Identical bits, opposite rules — because temperament is a property of the type, not of the data it carries.

Every "change" to a photo strands the old object; every abandoned string in that loop is dead weight in RAM. Something has to notice the instant an object has no labels left and reclaim it — and Python's answer is to keep a running census of how many names point at each object. →

A LIST IS A BOX OF ARROWS, NOT A BOX OF SONGS
sys.getsizeof([]) is 56 bytes, and every slot you add costs exactly 8 more — one pointer. The titles themselves live elsewhere on the heap; the list only holds arrows to them. A million-song list is about 8 MB of pointers, no matter how long the songs are — which is why appending is cheap, and why every entry in a list is an alias, not a copy.
Wait — () is () is True, but [] is [] is False. Python keeps a single empty tuple and hands it out forever — safe, because a photo can never change under you. But every [] must be its own fresh object: a whiteboard has to be private, or two unrelated pieces of code would scribble on each other. Immutability is exactly what earns the tuple the right to be shared.
Wait — an empty string already weighs 41 bytes — pure header, before a single letter. Each plain ASCII character then adds exactly 1: sys.getsizeof("Blinding Lights") is 41 + 15 = 56. Because a string is a photo you can't crop it — so every += shoots a whole new, one-byte-longer photo and abandons the old one.
↺ immutability is a permission slip to share
Here's the thread that ties this section to the next two. Two names can safely point at one str, one 200, one (200, 245)because nobody can write through a name to change the thing underneath. Sharing and immutability are the same fact seen twice. It's why Python dares to keep one empty tuple, one integer 2, and one copy of a repeated title — and why it never dares to share a list.
Trace3 step the machine — idea · code · memory move together
Same id or new id? one +=, two temperaments
The exact same += runs on a string and on a list. The photo can’t be edited, so Python builds a replacement and slides the name onto it — a fresh id and a discarded original each time. The whiteboard absorbs the change in place and keeps its id. Press Next and watch the split.
str · immutablelist · mutablesame op · opposite id
The id ledger — photo (str) rebuilds · whiteboard (list) edits in placestrbuild
id check— building
PHOTO · strcan’t be edited0 built · 0 abandoned
✝ freed — tombstone shelf
'Levels'0x…A
'Levels (Live)'0x…B
WHITEBOARD · listedited in place0 built · 0 abandoned
In plain words
Under the hood
BUILDALU · the operation
Program · temperaments.py
names · type
Memory · ids + tombstones
registers — same op, opposite outcome
live objects id + per-lane tally
what’s happening
beat 1 / 1

04Reference counting: the label census

Every object CPython builds carries a tiny header stamped in front of it, and the very first thing in that header is a plain integer. It answers one question, over and over: how many arrows are pointing at me right now? That integer is the object's reference count. An arrow — a reference — is anything that currently holds the object: a name you bound it to, a slot in a list, a value in a dict, an attribute hanging off another object, even the invisible argument slot while a function runs. Add an arrow and CPython nudges the counter up by one; take an arrow away and it nudges it down. Every way you will ever gain or lose a reference — and you'll meet dozens — pulls on this same little lever.

The counter exists to answer exactly one further question: can this memory be reused yet? The instant the count falls to zero, no arrow points at the object and nobody can ever reach it again. So CPython frees it on the spot, right there in the line that dropped the last reference. Not "sometime later," not "when memory runs low," not "when a collector wakes up." Immediately and deterministically. This is the quiet engine under everything you do in Python: a running census of arrows, and a funeral the moment the last one leaves.

THE ONE IDEA TO CARRY FORWARD
An object doesn't own its names — its names own it. CPython keeps a live tally of how many references point at each object, and the object is freed the exact instant that tally hits zero. Names, list slots, dict values, arguments — every arrow counts the same, and every object dies the same way: alone, when its last label walks off.
What sits in front of every objectfigure
three arrows point at one list — so its reference count reads 3 track a name playlist[0] a list slot faves["wknd"] a dict value ob_refcnt 3 ob_type list the header — two machine words, in front of every object ['Blinding Lights', 'The Weeknd', 200] the payload — what you think of as "the list"
Fig 04 — Before a single element of your list lives in memory, CPython writes a header: a reference count and a pointer to the object's type. The counter isn't metadata you add on — it's the first field of the object itself. Here three references are live, so it reads 3.

Now read one operation that almost everyone misreads: del. When you write del track, you are not deleting the list. You are removing a label. You cut the track arrow, the counter drops by one, and that is all. If any other arrow still points at the list, it lives on, perfectly happy, now answering to a different name. The object dies only when its last arrow disappears. Rebinding does the same quiet arithmetic from the other side: x = other drops whatever x used to hold, so that object loses an arrow, and hands the new object one. Bindings and dels are just arrows being drawn and erased. The census does the rest.

Walk the playlist and watch the tally move. track = [...] creates the list with one arrow — count 1. alias = track aims a second name at the very same list — count 2, two labels on one object. del track erases one label — count back to 1, and the list is unbothered. del alias erases the last — count 0, and the memory is reclaimed in that instant. Slide through it below.

Run the census yourself — add and drop referencesinteractive
every live arrow is +1 on the counter · at 0 the object is freed track name alias name playlist[0] list slot faves["wknd"] dict value queue[-1] list slot render(track) arg list object ['Blinding Lights', 200] ob_refcnt 2 sys.getrefcount(track) would say 3 refcount 0 → freed, memory reclaimed
refcount = 2
Slide up to draw more arrows (names, then a list slot, a dict value, another slot, a live argument); slide down to erase them. At exactly 0 the object dies. Notice the grey line: sys.getrefcount always reports one more than the true count — the next figure explains why.
Fig 04b — The counter is nothing but the number of live arrows. It never gets "found" or scanned — it's updated the moment each reference appears or vanishes, and the object is reclaimed the instant it reads zero.

You can audit the census yourself with sys.getrefcount, but it comes with one honest quirk. To hand the object to getrefcount so it can read the count, Python must put the object in the function's argument slot — and that slot is itself a reference. So during the call there is always one extra, borrowed arrow. A list with a single real name reports 2: one true, one borrowed by the very act of looking. Read every number it gives you as "one too high, on purpose."

WHY getrefcount ALWAYS READS ONE HIGH
The measurement can't be free. To inspect the object, getrefcount must hold it as its argument for the duration of the call — that's a real reference while the call runs. One name in, count comes back 2. It isn't a bug or an off-by-one to fix; it's the observer showing up in its own measurement. (Peek without disturbing it and the true count is 1 — the deeper cut below does exactly that.)
census.pypython
import sys

track = ["Blinding Lights", "The Weeknd", 200]
print(sys.getrefcount(track))   # 2  -> 1 real name + 1 borrowed by the call

alias = track
print(sys.getrefcount(track))   # 3  -> track, alias, + the borrowed one

del alias                       # removes the NAME alias, not the list
print(sys.getrefcount(track))   # 2  -> back down

del track                       # last label gone -> count 0 -> freed on the spot
↺ reframe
Stop picturing the name reaching out to hold the object. Picture the object keeping a headcount of everyone in the room. It has no idea what any of them are called — track, alias, "slot 0 of some list" are all just anonymous bodies to the counter. It knows only one number: how many. When that number reaches zero it turns off the lights and leaves, because a room nobody is standing in can never be entered again.

That "leaves the instant the room empties" is worth proving, because it's what makes Python's cleanup feel like clockwork rather than luck. Give an object a __del__ method — a hook CPython runs at the moment of the funeral — and you can watch the timing with your own eyes. The free doesn't drift to the end of the program or wait for some sweep. It happens inside the line that drops the last arrow.

deterministic.pypython
class Track:
    def __init__(self, name):
        self.name = name
    def __del__(self):          # runs the instant the last reference drops
        print("freeing", self.name)

t = Track("Blinding Lights")
a = t                          # two names, one object
del t                          # still alive -- 'a' holds it, count is 1
print("about to drop the last name")
del a                          # "freeing Blinding Lights" prints HERE, at once
print("...and only now do we get here")

✗ The myth

"Python has a garbage collector that runs later, in the background, and eventually gets around to freeing things — like Java or Go."

✓ The reality

The main engine is reference counting, and it's immediate: the object is gone the microsecond its count hits zero, in the line you wrote. Python does also ship a separate collector, but it exists only to mop up one thing refcounting can't handle on its own — reference cycles. It's a backstop, not the main act.

The deeper cut — the one case where "freed at zero" isn't the whole story

"The count hits zero and the object is freed" is a labelled simplification. It's true for the overwhelming majority of objects, and we'll refine it exactly once, here. Consider a list that contains itself: a = []; a.append(a). Now two arrows point at that list — the name a, and slot 0 inside the list. Run del a and the name goes, but the internal slot still points home, so the count lands on 1, never zero. The list is now unreachable — no name anywhere can find it — yet refcounting alone will keep it alive forever, a little island of memory pointing only at itself. This is the leak that pure reference counting cannot fix.

So CPython runs a second, occasional cyclic garbage collector whose entire job is to hunt down these unreachable islands and break them. It's why import gc exists. Unlike refcounting, it is not deterministic — it runs every so often, not at a precise moment. That's exactly why the myth above is half-right: there is a lazy collector, it's just the sidekick, not the star.

And if you want to see the true count without the borrowed +1 that getrefcount adds, you can read the header field straight out of memory. ctypes.c_ssize_t.from_address(id(obj)).value reads the very first machine word of the object — ob_refcnt itself — and reports 1 for a single-named list where getrefcount insists on 2. That header, by the way, is why the smallest possible object isn't free. sys.getsizeof(object()) is 16 bytes on a 64-bit build — 8 for the reference count, 8 for the type pointer — before the object holds a single thing.

Wait — if a lone name gives a list a refcount of 2, run sys.getrefcount(2) on the number 2 itself. You don't get 2, or 200, or even a few thousand. You get 4,294,967,295 — a number so large it's clearly a stand-in for "don't even try to count me." Who on earth is holding the integer 2 four billion times over — and why does Python refuse to ever let it die?

The answer isn't four billion variables — it's that some objects are declared immortal on purpose, minted once and shared by the entire program. Next: the small-integer shelf, where 2 and 200 already live, waiting, long before your code asks for them. →

Wait — because refcounting frees an object the instant its count hits zero, the memory is often reused immediately — so a brand-new object can be born at the exact id of one you just deleted. Run x = object(); a = id(x); del x; y = object() and id(y) == a often comes back True. The funeral and the next birth can share an address in the same breath.
THIS COUNTER IS WHY PYTHON HAS A GLOBAL LOCK
Every bind, every loop step, every function call nudges some object's reference count up or down — billions of times in a real program. If two threads nudged the same counter at once, the count could corrupt and free a live object. CPython's blunt fix is the GIL: one global lock, so only one thread touches counts at a time. The little integer in each object's header is a big reason a Python program can't trivially spread across all your cores — a thread we'll pick up much later.
"FREED AT ZERO" IS A TOOL WITH A SHARP EDGE
On CPython a file or socket is closed the exact moment its last reference drops — so even sloppy code without a with block often cleans up on time. But that timing is a CPython gift, not a language promise: the same code on PyPy or Jython, where cleanup is lazy, can leave files open for ages. Lean on instant death only when you know you're on CPython; otherwise use with.
Trace3 step the machine — idea · code · memory move together
The label census — every arrow is +1, and zero is a funeral
One list object; a handful of names, a list slot, and a del. Each thing that points at the object is one arrow — its reference count. Watch the count tick up and down one line at a time, and see the object freed on the exact line that drops its last arrow — never later. A live sys.getrefcount readout proves the observer itself adds one.
1 object · listrefs 1→3→0CPython
The object & its arrows — every arrow is +1list · ob_refcntborn
nametrack
namealias
slotqueue[0]
ob_refcnt
1
ob_type
list
['Blinding Lights', 200]
freed — memory reclaimed
sys.getrefcount(track) = 2 +1 borrowed by the call
In plain words
Under the hood
INCREFrefcount · the operation
Program · refcount.py
in scope · type
Memory · object + arrows
registers — the running state
arrows at this object the live-reference inventory
what's happening
beat 1 / 1

05The small-integer shelf

Here's a small confession about section 1. When you wrote seconds = 200, you pictured Python minting a brand-new integer for you — 28 fresh bytes, stamped 200, wrapped in a name. It did no such thing. Before your program ran a single line, CPython had already built every integer from −5 through 256 — 262 of them — and laid them out on a shelf that lives for the entire life of the interpreter. So 200 wasn't made; it was fetched. Your name got tied to an object that was already sitting there, pre-built, waiting.

Why those numbers? Because they're the workhorses. Loop counters climb through them. List indexes are them. Exit codes, boolean flags dressed as 0 and 1, the length of a song in seconds — the small integers show up constantly, in every program, in the interpreter's own guts. Minting a fresh 28-byte object every single time the digit 0 appears would be a storm of pointless allocation. So Python pre-pays exactly once, at startup, and then shares forever. And that answers the riddle you were left holding: your code's 2, an imported library's 2, and the 2 buried deep in the interpreter are not three twos. They are the one and only 2, wearing a crowd of different names.

THE ONE IDEA TO CARRY FORWARD
A name doesn't own its number. For the small integers, a name just points at a shared, pre-built object that half the running world is already pointing at too. = ties a label; it does not carve a fresh object. Keep that split — name here, object there, many-to-one — and the rest of this section is you watching it play out.
The shelf: −5 … 256, built once, shared by everyonefigure
built once at interpreter startup, then shared forever — the small-integer shelf: -5 -4 -3 0 1 2 3 255 256 257 → off the shelf your code: n = 2 a library: i = 2 interpreter internals sys.getrefcount(2) → 4294967295 — everyone points at this one object
Fig 05 — −5…256 are pre-built singletons. Every 2 in every program — yours, a library's, the interpreter's — is the same 2. Numbers from 257 up are not on the shelf.

You can catch the shelf red-handed with id(), which hands you an object's address in memory: two names with the same id are two labels on one object. But there's a trap laid across your path. Type the literal 300 twice in the same chunk of code, and Python's compiler may quietly reuse a single object for both — folding equal constants before your program draws its first breath, and faking a shelf where none exists. int("300") slips past that trick: the string only becomes a number while the program runs, long after the compiler has gone home. So any sharing you then observe is the shelf's doing and nothing else. That one precaution is the whole reason the snippet below reaches for int("...") instead of a bare literal.

shelf.pypython
import sys

print(sys.getrefcount(2))     # 4294967295 — a pegged flag on 3.12, not a live count

a = int("200")                # turned into a number while the program RUNS
b = int("200")
print(id(a) == id(b))         # True  — 200 comes off the shelf: ONE object

c = int("300")
d = int("300")
print(id(c) == id(d))         # False — 300 is past the shelf: built fresh each time
THE NUMBER YOU SEE IS A FLAG, NOT A CROWD
On this machine sys.getrefcount(2) returns exactly 4294967295 — which is 2³² − 1, a sentinel value, not a census of who's using the 2. Since Python 3.12 the shelf integers are immortal: their reference-count field is pinned at that flag and never actually ticks. On older Pythons the same call returns a large, jittery real count instead (tens of thousands, environment-specific). Either way the message is the same — an enormous crowd shares this object — the deeper cut has the mechanism.

Rather than take those two lines of output on faith, drive the boundary yourself. Slide the pointer along the number line below. Inside the shelf, both int("...") calls hand back the very same object, and id(a) == id(b) is True. Step one past 256 and the calls mint two separate objects — same value, different addresses — and the check flips to False. There is no gradual fade; there's a wall at −5 and a wall at 256.

Point at a value — one shared object, or two fresh ones?interactive
is the value you point at on the small-integer shelf? 200 the shelf — pre-built once −20 −5 256 300 a = int("200") b = int("200") 200 200 200 id(a) == id(b) → True on the shelf — one shared object, no allocation
200
Find 200 ("Blinding Lights") and 245 ("Titanium") — both sit safely on the shelf, so both share. Now nudge past 256 to 300 and watch the single object split into two. The wall is exact: 256 shares, 257 doesn't.
Fig 05b — Same value, different fate. Inside −5…256 the two calls return one object; one step past the wall and they return two. Identity, not value, is what changes.
Wait — if every 2 in the whole interpreter is literally the same object, and I write x = 2 then x = x + 1, did I just mutate the shared 2 into a 3 for everyone else?

No — and the reason is the quiet load-bearing fact under this whole section: integers are immutable. x + 1 never edits the object x points at. It computes a new value and hands back a different object — here, the shelf's pre-built 3. Then = re-aims the name x at that 3. The shared 2 is never touched; it just loses one of its many labels. Sharing is only safe because nobody can write through a name to change the object underneath. A shelf of mutable objects would be a catastrophe: one function's + 1 would silently corrupt every other 2 in the program.

↺ reframe
Stop imagining a small integer as private data your variable owns. Picture a public monument in the town square. Your n = 2 doesn't chisel your own personal 2; it plants a signpost aimed at the single civic statue of 2 that everyone shares. You can plant a million signposts. There is still exactly one statue — and no signpost can deface it.
THIS IS A CPYTHON DETAIL, NOT A LANGUAGE RULE
The shelf is a performance optimization inside CPython. Its exact range — even whether it exists — varies by implementation and version. Never write logic that leans on two equal numbers sharing an id. To ask "are these the same value?", compare values with ==. Save is for asking "are these the same object?", which is a genuinely different — and rarer — question.

✗ The myth

"200 is 200 comes out True, so is is just a fancy == — I'll use it to compare numbers."

✓ The reality

is asks same object?; == asks same value?. The shelf makes them accidentally agree for 200 and openly disagree for 300 (int("300") is int("300") is False, yet == is True). You'll hit this same trap again in chapter 4, where the operators live — now with the mechanism exposed.

The deeper cut — immortality, interned strings, and one honest simplification

Three refinements, then one confession. One — the flag. Since Python 3.12 (PEP 683) the shelf integers are immortal: their refcount field is pinned at the sentinel 4294967295 (that's 2³² − 1) and never increments or decrements. So getrefcount(2) isn't lying; it just isn't counting. It's showing you a "do not collect, ever" marker. On pre-3.12 Pythons you'd see a real, wobbling number in the tens of thousands instead. Two — ints aren't the only shelf. CPython also interns short, identifier-like strings so that repeated names share one object, and sys.intern() lets you force any string onto that shelf by hand — a real win when the same text is used as a dictionary key thousands of times. Three — this is the machinery under is. Every "why is is weird on numbers and strings?" you'll ever meet traces back to exactly this: identity caches make is answer True for shared objects and False otherwise, which has nothing to do with equality.

And the confession: when I said the shelf costs no allocation, ever, that was a labelled simplification. The truth is that those 262 objects were each allocated exactly once, at interpreter startup — real bytes, really reserved. What's free is every use after that first moment: fetching a shelf integer allocates nothing, because the object already exists. "Pre-pay once, then share for free" is the honest version, and it's the one worth keeping in your head.

Reference-counting can free an object the very instant its last name lets go — immediate, cheap, no stop-the-world sweep. It has exactly one blind spot, and it hides inside a loop of objects quietly holding one another alive →

THE WHOLE SHELF FITS IN 7 KILOBYTES
The pre-built integers −5…256 are 262 objects at 28 bytes each — about 7 KB, reserved once at startup and never freed. That one rounding-error of RAM erases millions of allocations: after that first moment, every 0, 1, 200, and loop counter in the whole program is fetched for free. Pre-pay once; share for the life of the interpreter.
A SECOND KIND OF SHARING, EASY TO CONFUSE
Type a = 1000; b = 1000 inside one function and a is b is True — even though 1000 is far past the shelf. That's not the shelf; it's the compiler folding equal constants in a single code block into one object. Type the same two lines separately at the REPL and a is b is False. Two different sharing tricks — the runtime shelf and compile-time folding — and telling them apart is exactly why the section reaches for int("1000"), which dodges both.
Wait — the shelf makes is and == accidentally agree on 200. There's a value where they violently disagree: x = float("nan"). Then x is x is True (it's one object) yet x == x is False (NaN equals nothing, not even itself). Same object, not equal — the mirror image of two shelf integers that are equal but need not be the same object. Proof, from both sides, that is and == ask different questions.

06The blind spot: reference cycles

Refcounting, from the last section, lives by one iron rule: free at zero, and only at zero. Every object carries a tally of how many references point at it. The instant that tally hits zero, the memory is handed back — no pause, no sweep, no ceremony. It is fast, simple, and correct for almost everything you will ever write. Almost. There is exactly one shape it cannot see, and now you're going to build it on purpose.

Take our two canon tracks and make a circular playlist — the last song loops back to the first, the way a real player does when repeat is on. Write a.next = b, then b.next = a. Now you have two objects, each pointing at the other. Delete both names: del a, del b. Then run the census. Object a still has one reference — it's sitting in b.next. Object b still has one — it's sitting in a.next. Neither tally is zero, so refcounting frees neither. Yet no name in your program, no line of your code, can reach either object anymore. They are an unreachable island: two objects propping each other up in memory forever, while the refcounter stares at "1" on both and shrugs. Left alone, that is a genuine memory leak.

THE ONE IDEA TO CARRY FORWARD
Refcounting answers a purely local question — "how many arrows point at me?" — and a cycle makes that local answer lie. Two objects can each honestly report "1 reference," and both be garbage. That gap between referenced and reachable is the whole reason CPython needs a second collector.

So CPython runs a backstop on top of refcounting: the cycle collector, exposed as the gc module. Periodically it goes hunting for exactly these islands — clumps of objects reachable only from each other — and frees them as a group. You almost never think about it; it fires on its own schedule while refcounting does the everyday work instantly. But you can force a hunt right now with gc.collect(), which returns the number of objects it reclaimed. Watch it find our island:

circular_playlist.pypython
import gc

class Track:
    def __init__(self, title):
        self.title = title
        self.next  = None

a = Track("Blinding Lights")   # 200 s
b = Track("Titanium")          # 245 s
a.next = b                     # a  →  b
b.next = a                     # b  →  a  : the playlist loops

del a
del b                          # both NAMES gone — each object still props up the other
print(gc.collect())            # 2  — the cycle collector found the island, freed both

Two objects created, two objects reclaimed. Refcounting did the everyday work for free, and the cycle collector was the one tool that could see past the "1 each" lie. To feel why it has to exist, look at the two questions being asked side by side.

Two different questions: "referenced?" vs "reachable?"figure
the roots — live names & frames name a name b unreachable island "Blinding Lights" refs: 1 "Titanium" refs: 1 .next .next refcounting asks "how many arrows?" → 1 each → KEEP reachability asks "any path from a root?" → none → FREE
Fig 06 — The island's two objects each hold one incoming reference, so refcounting votes to keep them. But no path runs from the roots (your names and frames) to the island, so they are garbage. Only a collector that walks reachability, not local counts, can tell the difference.

Now watch the whole life of that island unfold, one move at a time. Drag the step control: create the two tracks, loop them into a cycle, delete the names, then let the collector sweep. Keep your eye on the two refs tallies. The moment they get stuck above zero with the names gone is the exact blind spot refcounting can't cover.

The life & death of a reference cycleinteractive
a circular playlist — two tracks that point at each other a b "Blinding Lights" refs: 1 "Titanium" refs: 1 .next .next Two Track objects live in the heap — one name pointing at each. Both are reachable from your code.
0 · create
Walk it: createlink into a cycledel the namesgc.collect(). At step 2 the tallies read refs: 1 with no name attached — that is the leak refcounting alone can never close.
Fig 06b — The tallies climb 1 → 2 → 1 → 0. Refcounting handles every step except the third; the cycle collector is what turns that stubborn 1 into a 0.
You almost never call it yourself
CPython fires the cycle collector automatically once enough container allocations pile up since the last sweep — no gc.collect() in sight. In a quick test, building 5,000 short-lived cycles triggered 18 automatic collections that reclaimed thousands of objects on their own. Manual gc.collect() is for demos, tests, and the rare long-running process that spawns cycles faster than the schedule catches them. Everything acyclic is still freed the instant its count hits zero.
Wait — if no name reaches the island, how does the collector find it in the first place? It can't follow a path that doesn't exist. The trick is that it doesn't search from the island — it starts at the known roots (your live names, the call stack, module globals), marks everything reachable from them, and whatever it never touches is, by definition, garbage. The island is invisible to any path from the roots, which is exactly what condemns it.
↺ reframe
Refcounting is a diligent clerk who only ever counts the arrows touching one object at a time — brilliant at the desk, blind to the shape of the whole graph. The cycle collector is the auditor who occasionally walks the entire building from the front door and asks a different question: not "who is pointed at?" but "who can still be reached?" A cycle is the one place those two questions give opposite answers.

✗ The myth

"Python has a garbage collector, like Java — it's the thing that frees your memory." So people reach for gc.collect() whenever memory feels high.

✓ The reality

Refcounting frees the overwhelming majority of objects, instantly, the moment their count hits zero — no collector involved. The gc module is a narrow backstop for the one shape refcounting can't see: cycles. Most programs would run fine, just leakier, with it switched off.

The deeper cut — "refs: 1 forever" is a labelled simplification

Saying the island's counts sit at "1 each, forever" is a labelled simplification. It's true enough to see the leak, but here's the truth underneath. First, the collector doesn't just eyeball those counts. It walks the container objects it's tracking and subtracts the references that live inside the group itself. If an object's count drops to zero once you ignore all the internal, cycle-only arrows, then nothing outside the group holds it — so the whole clump is unreachable and can be freed together. That's how it tells a genuine island apart from a cycle still pinned by one live outside reference.

Second, the collector doesn't track everything. Only objects that can contain references — lists, dicts, class instances, tuples — can ever form a cycle, so those are all it watches. Atoms can't: gc.is_tracked(200) and gc.is_tracked("Titanium") are both False, while gc.is_tracked([]) is True. (CPython even untracks a container that provably holds only atoms — a fresh empty {} reports False — a small optimisation that keeps each sweep cheap.)

Third, you don't need cycles this elaborate. A single list that contains itself, L = []; L.append(L), is already a one-object island: del L leaves its count at 1, and only gc.collect() frees it. And the classic escape hatch is a weak reference. b.next = weakref.ref(a) points at a without bumping its count, so the back-edge never forms a real cycle, and plain refcounting cleans up the instant the names go — leaving gc.collect() nothing left to do.

One more myth worth killing, since older tutorials still repeat it: cycles containing objects with a __del__ finalizer used to be uncollectable, quarantined in gc.garbage. Since PEP 442 (Python 3.4) they're collected normally. On this machine, Python 3.12.7, a cycle whose objects define __del__ still reclaims cleanly and leaves gc.garbage empty.

Every object, you now know, carries two separate truths: its value (what it is) and its identity (which box in memory it is). The next chapter hands you the operators that interrogate each — == asks "same value?", is asks "same object?", and they don't always agree — plus the arithmetic where 7 / 3 flatly refuses to hand you an integer →

THE COLLECTOR IS OPTIONAL; REFCOUNTING ISN'T
Run gc.disable() and the overwhelming majority of your program frees on exactly the same schedule as before — the instant each count hits zero. You've only switched off the backstop for cycles. Long-running services sometimes do this on purpose to dodge the collector's occasional pause, accepting a slow cycle leak in trade. Refcounting is the engine; the cycle collector is a sidecar you can unbolt.
Wait — you don't have to try to build a cycle — you make them constantly. A track that knows its .next and its .prev, a tree node that holds its .parent while the parent lists its children — every back-pointer closes a loop. That's why GUIs, HTML DOMs, and database ORMs, all built from objects pointing back at their owners, keep the cycle collector quietly busy all day.
Wait — the collector doesn't scan everything equally. It sorts tracked objects into three generations and checks the youngest far more often than the old survivors — betting that "most garbage dies young," which is usually right. The catch: a cycle that outlives its first couple of sweeps gets promoted and inspected more rarely, so an unreachable island can linger through many collections before the one that finally frees it. Instant for counts, patient for cycles.
Trace3 step the machine — idea · code · memory move together
The blind spot — two objects that keep each other alive
Build a two‑track loop, then delete both names one at a time. Each object's count stalls at 1 with no name in sight — the exact gap between referenced and reachable that forces a second collector to walk the graph from the roots.
2 nodesrefcount + cyclic GCfrees 2
The object graph — roots reach in, the collector walks outgraph · refscreate
ROOTS frame · globals a b .next .next refs 1 A Blinding Lights refs 1 B Titanium
In plain words
Under the hood
NEWALU · the operation
Program · cycle.py
names · type
Memory · roots + object graph
registers — the running state
object graph per‑node refs · reachability
gc.collect() →
what's happening — referenced vs reachable
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 03, in working code

In Python a name is not a box you pour a value into — it's a label stitched onto an object that already exists in memory. This chapter watches those labels get tied, re-tied, and cut, so you can always tell aliasing from copying, and mutation from rebinding.

A name is a label
Assignment ties a name to an object. Rebind the name and the old object is simply left behind — untouched.
Two names, one object
When two names point at the same mutable object, a change made through one is visible through the other. That's aliasing.
is vs ==, and copies
== asks 'equal contents?'; is asks 'literally the same object?'. Making a real copy is how you break an alias.
Rebind vs mutate
Rebinding moves one label to a new object; mutating edits the object every label shares. += quietly picks one or the other by type.
Immutability & unbinding
Tuples refuse item-assignment, del cuts a label entirely, and a tuple can still hold a mutable list inside it.
end of chapter 03 · six sections
iolinked.com
Written by Ajai Raj