◈ python mapVol 1 · Ch 03/14
Volume 1 Python, from the metal up · chapter 03

03Names & memory

In Chapter 2 we learned that every value is an object. It stands on its own in memory, carrying its own type and its own moves. In this chapter we ask the other half of the question: how does a name find that object? Most courses answer with a box that holds a value. It's the wrong picture, and it sits behind nearly every surprise Python has in store. So here's the plan. We'll watch a name land on an object like a sticky note. Then we slap a second note on the same object and see what happens when one of them scribbles on it. We'll split the values you can edit in place from the ones you can't. And we'll pry open Python's memory manager while it runs a quiet label census. It hands out small integers from a shelf it built before you woke up. Then it hits the one blind spot where that census lies to itself: reference cycles. The whole way through we keep one question 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 answer that on sight. 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.

iolinked · chapter 03 — the checkpoints6 steps
$ sections covered in Names & memory
01A name is a sticky note, not a box
02Two labels, one object: aliasing
03Whiteboards and photos: mutable vs immutable
04Reference counting: the label census
05The small-integer shelf
06The blind spot: reference cycles

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 watch what actually happens, because it's 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 at all. Second, the name seconds gets written into a little table of names Python keeps for you, the namespace. Third, the = copies nothing into nothing. Instead it binds the name to the object, the way you press a sticky note onto a jar already on the shelf. Name and 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. Watch: write seconds = "3:20" and the very same name lifts off the int and lands on a brand-new str object. That's a different thing, at a different address, of a different type. Now look hard 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.

SYNTAX · the assignment formssix shapes of = — and every one of them aims names, never copies values
name = object — the plain bind: one name aimed at one object a = b = c = object — chained: ONE object, three names aimed at it a, b = 200, 245 — multiple: pack the right side, unpack into the left a, b, c = iterable — unpacking: the counts must match, exactly first, *rest = iterable — starred: one name swallows the leftovers (always a list) a, b = b, a — the swap: the right side is built FIRST, then unpacked
Type 1a = b = c = 0 builds one object and aims three names at it. Not three zeros — three arrows into one shelf integer.
Type 2one, two = 200, 245 packs the right into a tuple first, then unpacks it left to right. Two objects, two names, one line.
Type 3x, y, z = [200, 245, 203] unpacks any iterable — a list, a tuple, a string, the pieces .split() hands back.
Type 4first, *rest = … gives rest everything left over, and rest is always a list — even when it catches nothing at all.
Type 5a, b = b, a needs no temporary. The tuple on the right is built before any name moves, so both old values are already safe inside it.
you type
a = b = c = 0
print(a, b, c)

one, two = 200, 245
print(one, two)

x, y, z = [200, 245, 203]
print(x, y, z)

first, *rest = [200, 245, 203]
print(first, rest)

one, two = two, one
print(one, two)
you see
0 0 0
200 245
200 245 203
200 [245, 203]
245 200
where beginners trip
  • a, b = 200, 245, 203 raises ValueError: too many values to unpack (expected 2). The counts are law, and Python checks them before a single name moves.
  • a, b, c = [200, 245] raises the mirror error: ValueError: not enough values to unpack (expected 3, got 2).
  • first, *rest = 200 raises TypeError: cannot unpack non-iterable int object — unpacking needs something it can walk through.
  • a = b = [] does not make two empty lists. It makes one, with two names on it — chaining is aliasing, born on line one.
  • two stars in one target is a SyntaxError: multiple starred expressions in assignment. Exactly one name may swallow the rest.
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 rebind▸ drag me
① 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. 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 and 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, like PyPy or 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?", which is the whole basis of the next section. 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 with the int 200 in step ④, CPython reclaims that memory on the spot by counting references. We'll open up that mechanism 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? →

TYPE THIS · save it, then run itunpack_parse.py — a line of text taken apart by assignment alone
# unpack_parse.py -- one line of text, taken apart by assignment alone

line = "The Weeknd - Blinding Lights - 200"

artist, title, seconds = line.split(" - ")
print(artist, "|", title, "|", seconds)

seconds = int(seconds)
minutes, leftover = seconds // 60, seconds % 60
print(f"{title} runs {minutes}:{leftover:02d}")

setlist = ["Blinding Lights - 200", "Titanium - 245", "Levitating - 203"]

opener, *later = setlist
print(opener)
print(later)

head, *body, tail = setlist
print(head, "||", body, "||", tail)

first, second = "Blinding Lights", "Titanium"
print(first, "then", second)
first, second = second, first
print(first, "then", second)
The Weeknd | Blinding Lights | 200
Blinding Lights runs 3:20
Blinding Lights - 200
['Titanium - 245', 'Levitating - 203']
Blinding Lights - 200 || ['Titanium - 245'] || Levitating - 203
Blinding Lights then Titanium
Titanium then Blinding Lights
Save it as unpack_parse.py and run it from the terminal. Every line here is assignment doing work you would otherwise write a loop for. The parser is one line: .split(" - ") hands back three pieces, and the three names on the left catch them in order. Count them yourself, because the counts really are law. Three pieces into three names works; three into two raises ValueError before a single name moves. Then watch the star. opener, *later = setlist gives opener the first item and hands later everything else, as a list — no indexing, no slicing, no counting. head, *body, tail = setlist is the same trick clamped at both ends, and the middle name still comes back a list even when it catches a single song. Finally the swap, which is the line worth staring at. first, second = second, first needs no temporary variable because the right-hand side is built first, into a tuple, before any name is touched. Both old values are already sitting safely in that tuple when the left side starts rebinding. Two things to try. Add a fourth field to line and read the error — it names the exact count it wanted and the count it got. Then replace *later with a plain later and watch a working line turn into a ValueError, because one name can no longer hold two songs.
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 anything▸ drag me
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 shallow-vs-deep 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, different objects. But an alias 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.

SYNTAX · is vs ==two operators, two genuinely different questions — and they can disagree
a == b — same VALUE? asks the object, by running its __eq__ a is b — same OBJECT? compares two addresses; no method runs at all a is not b — the negation. write it this way, never not a is b x is None — the one place `is` belongs in everyday code id(a) == id(b) — what `is` does, spelled out the long way
Type 1== asks the object. A class can define __eq__, so == can be taught to mean whatever that class decides.
Type 2is asks the machine. It compares two addresses and stops. Nothing can override it, and nothing can slow it down.
Type 3An alias is the case where both answer True: after b = a the two names hold one object, so it is equal to itself and identical to itself.
Type 4Equal but not identical is the ordinary case. Two lists typed out separately hold the same songs in two different objects.
Type 5None is a singleton — one object for the whole program — so x is None is literally the right question to ask.
you type
a = ["Blinding Lights", "Titanium"]
b = a
c = ["Blinding Lights", "Titanium"]

print(a == b, a is b)
print(a == c, a is c)

nothing = None
print(nothing is None, nothing is not None)

print(int("200") is int("200"))
print(int("300") is int("300"), int("300") == int("300"))
you see
True True
True False
True False
True
False True
where beginners trip
  • reaching for is to compare numbers or text. Python itself warns you: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
  • generalising from int("200") is int("200") being True. int("300") is int("300") is False — that is the shelf in section 5, not a rule about numbers.
  • writing not a is b. It parses as not (a is b) and happens to work, but a is not b is the shape every Python reader expects.
  • x == None. It usually works, yet a class can redefine == and lie about it. x is None cannot be redefined by anyone.
  • treating is as a stricter ==. They are different questions: float("nan") is itself and is not equal to itself.
Step through the script — watch when an edit reaches a▸ drag me
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.

⚠ MOST BEGINNERS THINK…reaching into a list and holding onto what you pulled out
Names share objects — that is this chapter's whole lesson — so first = songs[0] hands me the list's first slot, and re-pointing first edits the playlist through it.
TYPE THIS — 10 SECONDS
>>> songs = ["Blinding Lights", "Titanium"]
>>> first = songs[0]
>>> first = "Levels"        # re-point the NAME
>>> songs                    # ...did the list move?
>>> songs[0] = "Levels"     # re-point the SLOT
>>> songs                    # ...and now?
['Blinding Lights', 'Titanium']
['Levels', 'Titanium']
songs[0] handed you the object, never the slot. first = "Levels" peels one sticky note off and lands it somewhere else — it cannot reach back into a list it was only ever read from. songs[0] = "Levels" is the move that bites, because a slot is an arrow too, and that line re-aims the list's own.
SYNTAX · alias vs copyone shape makes a second name; four make a second object
y = x — ALIAS. no new object. an edit through y is visible through x y = x.copy() — COPY. a new list, holding the SAME item objects y = x[:] — COPY. the whole-slice idiom; identical result y = list(x) — COPY. the constructor builds a new one too y = copy.deepcopy(x) — COPY, all the way down. needs import copy
Type 1Plain = only ever aliases. There is no second kind of assignment hiding somewhere — x is y comes back True, every time.
Type 2.copy(), [:] and list(x) are three spellings of one act: build a new outer list and fill its slots.
Type 3It only matters on mutables. Copying a str or an int buys you nothing, because nobody could have edited it anyway.
Type 4A copy is shallow. It duplicates the arrows, not the things they point at, so nested lists are still shared between the two.
Type 5copy.deepcopy(x) follows every arrow and rebuilds the whole tree. Slower, and the only way to sever every layer at once.
you type
x = ["Blinding Lights", "Titanium"]

y = x
y.append("Levels")
print(x)

y = x.copy()
y.append("Wake Me Up")
print(x)
print(y)

z = x[:]
print(z == x, z is x)

nested = [["Blinding Lights", 200]]
shallow = nested.copy()
shallow[0].append("The Weeknd")
print(nested)
you see
['Blinding Lights', 'Titanium', 'Levels']
['Blinding Lights', 'Titanium', 'Levels']
['Blinding Lights', 'Titanium', 'Levels', 'Wake Me Up']
True False
[['Blinding Lights', 200, 'The Weeknd']]
where beginners trip
  • calling y = x a backup. It is a second label on the one list, so there is no backup and nothing to restore.
  • .copy() on a list of lists. The inner lists are still shared — shallow[0] and nested[0] are one object, as the last line proves.
  • checking a copy with ==. It answers True whether you copied or aliased; only is tells the two apart.
  • reaching for .copy() on a photo. "Levels".copy() raises AttributeError — an immutable never needs one.
  • forgetting that dict and set copy the same way, while a tuple has no .copy() at all, for the same reason a string doesn't.
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 — same object, so any alias (and a is b stays True) sees the new song. But b + ["Levels"] builds a brand-new list and b = rebinds b onto it, leaving a on the old one (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 →

TYPE THIS · save it, then run italias_bug.py — the “my backup changed too” disaster, staged and then fixed
# alias_bug.py -- the classic "my backup changed too" disaster, then the fix

original = ["Blinding Lights", "Titanium"]
backup = original                     # we THINK this is a backup

print("same object?", original is backup)
backup.append("Levels")               # we edit only the backup...
print("original:", original)          # ...and the original moved too
print("backup  :", backup)

print("-" * 46)

original = ["Blinding Lights", "Titanium"]
backup = original.copy()              # the fix: one genuinely new list

print("same object?", original is backup)
print("equal?     ", original == backup)
backup.append("Levels")
print("original:", original)
print("backup  :", backup)
same object? True
original: ['Blinding Lights', 'Titanium', 'Levels']
backup  : ['Blinding Lights', 'Titanium', 'Levels']
----------------------------------------------
same object? False
equal?      True
original: ['Blinding Lights', 'Titanium']
backup  : ['Blinding Lights', 'Titanium', 'Levels']
Save it as alias_bug.py and run it. The first half is the most common bug in beginner Python, staged so you can watch it land. backup = original reads like a save. It is a second sticky note. The very first line of output is the whole story: original is backup prints True before you have touched anything, which means one list and two names. So when backup.append("Levels") runs, there is no backup standing between the edit and the original — the append lands in the one object both names hold. Both lines print the same three songs, and nothing anywhere warned you. The second half is the fix, and it is one method call. original.copy() builds a genuinely new list and fills its slots with the same item objects. Now is says False while == says True, and that pair is exactly the state you wanted all along: same contents, separate objects. Append to the copy and the original does not move a millimetre. Two things to try. Swap .copy() for [:] or list(original) and confirm all three spellings behave identically. Then delete the .copy() from the second half and watch the fix collapse straight back into the bug — one method call is the entire difference between a backup and a story you told yourself.
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. 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, editable only by shooting a new one. That single split is whether the object can be changed from the inside. And that is exactly 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. Notice 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. Contrast a list's append: that 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, keeping the 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. Identical syntax, and yet one mutates while the other replaces. The difference lives entirely in the object's temperament, never in the +=.

SYNTAX · augmented assignmentone operator, two temperaments — the trap this chapter exists to disarm
n += 45 — int: read, add, REBIND. a new object, a new id s += " (Live)" — str: the same story. a photo cannot absorb a change lst += ["Levels"] — list: __iadd__ MUTATES in place. same id, every alias sees it lst = lst + ["Levels"] — NOT the same line. builds a new list, then rebinds d[key] += 1 — read the slot, add, write the slot back -= *= /= //= %= **= — every one of them follows the identical rule
Type 1On an immutableint, float, str, tuplex += y is x = x + y. A new object is built and the name is moved onto it.
Type 2On a list, += calls __iadd__ and edits the object in place. The id never changes, so every alias is edited too.
Type 3That split is the whole trap. With an alias in play, lst += other and lst = lst + other give different answers.
Type 4+= on a list accepts any iterable: lst += "ab" appends two characters. Plain + demands a list and raises otherwise.
Type 5The tell is always id(). Same id means the object absorbed the change; new id means you are holding a replacement.
you type
n = 200
before = id(n)
n += 45
print(n, id(n) == before)

title = "Levels"
before = id(title)
title += " (Live)"
print(title, id(title) == before)

queue = ["Blinding Lights"]
alias = queue
before = id(queue)
queue += ["Titanium"]
print(queue, id(queue) == before)
print(alias)

queue = ["Blinding Lights"]
alias = queue
queue = queue + ["Titanium"]
print(queue)
print(alias)
you see
245 False
Levels (Live) False
['Blinding Lights', 'Titanium'] True
['Blinding Lights', 'Titanium']
['Blinding Lights', 'Titanium']
['Blinding Lights']
where beginners trip
  • assuming += always rebinds. On a list it mutates, and every other name on that list feels it immediately.
  • assuming lst += other is just shorthand for lst = lst + other. The last four lines of output prove they are not.
  • counter += 1 on a name never bound raises NameError. Augmented assignment reads the name before it writes it.
  • typing =+ for +=. n =+ 45 quietly rebinds n to 45 and no error ever fires.
  • lst + "ab" raises TypeError: can only concatenate list (not "str") to list, while lst += "ab" cheerfully appends 'a' and 'b'.

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 superpower. 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. That's the whole aliasing surprise from the last section, finally explained: 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 is two people holding copies of a photo that can never change.

NOW PREDICT IT YOURSELFthree tiny programs — write the output down before you run a line
Three drills, and the honest way to do them is on paper first. Case A: set a = [200, 245], then b = a, then b += [203], then print(a). Case B: the same three lines, except the third reads b = b + [203]. Case C: set a = 200, then b = a, then b += 45, then print(a, b). Two of these three print something people find genuinely surprising. Before you type anything, ask the only question that decides the answer: does this line edit the object, or does it move a name? Then run all three and check yourself. If you got A and B the same, you have just found the exact character where a real bug hides.
show the solution
# CASE A ------------------------------------------------------
a = [200, 245]
b = a
b += [203]
print(a)                # [200, 245, 203]
# WHY: += on a LIST calls __iadd__ and edits the object in place.
#      a and b are one object, so a sees the new item appear.

# CASE B ------------------------------------------------------
a = [200, 245]
b = a
b = b + [203]
print(a)                # [200, 245]
# WHY: b + [203] BUILDS a new list; then = aims b at that new one.
#      a never moved, and the object a holds was never touched.

# CASE C ------------------------------------------------------
a = 200
b = a
b += 45
print(a, b)             # 200 245
# WHY: an int is immutable, so += cannot edit it. Python builds
#      the int 245 and rebinds b. a still points at the 200.
↻ 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 function. Because an immutable value is frozen for life, Python can take a permanent fingerprint of it, a hash, and trust that fingerprint forever. That's 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'. That's why song titles (strings) make fine dict keys, and a (200, 245) tuple slots happily into a set, but a bare list never can.

ERROR CLINICnames that are not there, and objects that will not be written on
Traceback (most recent call last):
  File "rename.py", line 4, in <module>
    print(titel)
          ^^^^^
NameError: name 'titel' is not defined. Did you mean: 'title'?
A name is a key in a table, not a box with something in it. Python looked up the exact spelling titel in the namespace, found no such key, and stopped — and since 3.12 it kindly offers the nearest key it does hold. Case counts too: Title and title are two different keys.
fix the spelling - or bind the name before the line that reads it
Traceback (most recent call last):
  File "freeze.py", line 4, in <module>
    track[1] = 250
    ~~~~~^^^
TypeError: 'tuple' object does not support item assignment
A tuple is a photo, not a whiteboard. You may read every slot in it, but nothing may re-aim one — that is the entire content of the word immutable. Note where the carets point: at the [1], the slot, not at the 250.
build a replacement - track = (track[0], 250) - or use a list if it has to change
Traceback (most recent call last):
  File "queue.py", line 5, in <module>
    songs.append("Wake Me Up")
    ^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'append'
The crash is on line 5, but the mistake is on line 4: songs = songs.append("Levels"). .append() mutates the list and hands back None, so catching its answer threw the playlist away and left songs pointing at Python's deliberate nothing.
call a mutator for its effect, never its value - songs.append("Levels") on a line of its own
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 slider▸ drag me
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 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. Elements that are all photos — (200, 245) — 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. →

NOW FIX IT YOURSELFone template, two setlists, and a bug that quietly eats all three
Here is a bug that has ruined real data. Start with TEMPLATE = ["Blinding Lights", "Titanium"]. Build Monday's setlist from it — monday = TEMPLATE — and append "Levels". Build Tuesday's the same way — tuesday = TEMPLATE — and append "Wake Me Up". Print all three names, then print monday is tuesday. You expected a two-song template and two three-song setlists. What you get is one four-song list printed three times, and an is that answers True. Type it, watch it fail, then fix it with a single method call so the template keeps its two songs and each day owns its own list. Prove the fix with is rather than ==, because only is can tell you whether a second object was ever built.
show the solution
# THE BUG ----------------------------------------------------
TEMPLATE = ["Blinding Lights", "Titanium"]

monday = TEMPLATE
monday.append("Levels")

tuesday = TEMPLATE
tuesday.append("Wake Me Up")

print("template:", TEMPLATE)
print("monday  :", monday)
print("tuesday :", tuesday)
print("monday is tuesday ->", monday is tuesday)

# template: ['Blinding Lights', 'Titanium', 'Levels', 'Wake Me Up']
# monday  : ['Blinding Lights', 'Titanium', 'Levels', 'Wake Me Up']
# tuesday : ['Blinding Lights', 'Titanium', 'Levels', 'Wake Me Up']
# monday is tuesday -> True

# THE FIX ----------------------------------------------------
TEMPLATE = ["Blinding Lights", "Titanium"]

monday = TEMPLATE.copy()
monday.append("Levels")

tuesday = TEMPLATE.copy()
tuesday.append("Wake Me Up")

print("template:", TEMPLATE)
print("monday  :", monday)
print("tuesday :", tuesday)
print("monday is tuesday ->", monday is tuesday)

# template: ['Blinding Lights', 'Titanium']
# monday  : ['Blinding Lights', 'Titanium', 'Levels']
# tuesday : ['Blinding Lights', 'Titanium', 'Wake Me Up']
# monday is tuesday -> False
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. The very first thing in that header is a plain integer that 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. It might be 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 pulls on this same little lever, and you will meet dozens.

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 (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.

SYNTAX · delit deletes a name, or a slot — it never once targets an object
del name — remove the NAME. the object lives on if any arrow remains del a, b, c — several names cut in one statement del lst[i] — remove a SLOT. this one really does edit the list del lst[i:j] — remove a whole slice in one move del d[key] — remove a key from a dict
Type 1del track is a namespace delete. The key is erased from globals() and the object loses exactly one arrow.
Type 2The object is freed only if that arrow was the last one. Watch the census move: 3, then 2, and the list is still perfectly alive.
Type 3del track[1] is a different act entirely. It mutates the container, so every alias on that list sees the song vanish.
Type 4del is a statement, like import — not a function. No parentheses, no return value, nothing to assign.
Type 5Rebinding does the same arithmetic quietly. x = other drops one arrow from whatever x held and hands one to the new object.
you type
import sys

track = ["Blinding Lights", "Titanium", "Levels"]
alias = track
print(sys.getrefcount(track))

del alias
print(sys.getrefcount(track))

del track[1]
print(track)

lengths = {"Blinding Lights": 200, "Titanium": 245}
del lengths["Titanium"]
print(lengths)

del track
print("track" in globals())
you see
3
2
['Blinding Lights', 'Levels']
{'Blinding Lights': 200}
False
where beginners trip
  • reading del x as “free the object”. It cuts one arrow; any other arrow anywhere keeps the object alive and well.
  • using the name afterwards. del track then print(track) raises NameError: name 'track' is not defined.
  • del lst[5] on a shorter list raises IndexError: list assignment index out of range.
  • del t[0] on a tuple raises TypeError: 'tuple' object doesn't support item deletion — a photo has no slots to remove.
  • del d["nope"] raises KeyError: 'nope'. Reach for d.pop("nope", None) when the key might not be there.

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

Run the census yourself — add and drop references▸ drag me
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."

SYNTAX · the inspection toolkitfive one-liners from Ajai’s own memory notes — they make the invisible readable
id(obj) — WHICH object: its address, on CPython type(obj) — what KIND it is; the type lives on the object, never the name sys.getrefcount(obj) — how many arrows point at it (always +1 for the call itself) sys.getsizeof(obj) — what the object weighs, shallowly, in bytes gc.collect() — force a cycle sweep; returns how many objects it freed
Type 1id(a) == id(b) and a is b are the same question in two spellings. The second line of output shows them agreeing.
Type 2getrefcount reports one too many, always. Two real names here, so the honest count is 2 and it prints 3.
Type 3getsizeof weighs the box, never what the box points at. A 2-slot list is 72 bytes: 56 of header plus 8 a slot.
Type 4getrefcount(2) hands back 4294967295. That is a sentinel, not a census — since 3.12 the shelf integers are immortal.
Type 5gc.collect() is the only one that changes anything. The self-referencing list is unreachable, yet only the sweep can free it.
you type
import sys, gc

track = ["Blinding Lights", "Titanium"]
alias = track

print(type(track))
print(id(track) == id(alias), track is alias)
print(sys.getrefcount(track))
print(sys.getsizeof(track))
print(sys.getrefcount(2))

gc.collect()                # sweep first, so the next count is only ours
loop = []
loop.append(loop)           # a list holding itself: its count never hits 0
del loop
print(gc.collect())
you see
<class 'list'>
True True
3
72
4294967295
1
where beginners trip
  • quoting getrefcount’s number as the truth. It is the real count plus one, every single time, because the call holds the object while it runs.
  • expecting getsizeof to weigh the contents. A list of a thousand songs is still only 8 bytes a slot; the titles live elsewhere on the heap.
  • calling gc.collect() to “free memory”. Refcounting already freed everything acyclic; the collector only ever sweeps up cycles.
  • treating id() as a permanent fingerprint. Addresses get reused — a freed object’s id can turn up on the very next object you build.
  • these five are the part of Ajai’s 3memory management.py that needs no install. Its psutil and memory_profiler tools are third-party, and they wait for chapter 11.
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, since 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 directly out of memory. ctypes.c_ssize_t.from_address(id(obj)).value reads the very first machine word of the object, ob_refcnt itself. It 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 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 fever of pointless allocation. So Python pre-pays exactly once, at startup, and then shares forever. That is the answer to 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 right across your path. Type the literal 300 twice in the same chunk of code, and Python's compiler folds constants before your program draws its first breath. It may quietly reuse a single object for both, 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 the 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 but 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?▸ drag me
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. 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.

NOW WRITE IT YOURSELFequal, identical, or neither — catch all three with two operators
Three names, five printed lines, and by the end is will never confuse you again. Bind a to a two-song list, then b = a, then type out c separately with the same two songs. Print a == b, a is b and then a == c, a is c, and say out loud what each pair means before you move on. Now walk the wall from this section: print int("256") is int("256"), then int("257") is int("257") alongside == on the same pair. Same code, opposite answers, and == never once wavers. Finish with the value that breaks the other way — nan = float("nan"), then print(nan is nan, nan == nan). One object, and it disagrees with itself. Use int("…") rather than a bare literal throughout, so the compiler’s constant folding cannot fake a result on you.
show the solution
a = ["Blinding Lights", "Titanium"]
b = a
c = ["Blinding Lights", "Titanium"]

print(a == b, a is b)
print(a == c, a is c)

print(int("256") is int("256"))
print(int("257") is int("257"), int("257") == int("257"))

nan = float("nan")
print(nan is nan, nan == nan)

# True True    -> an alias: equal AND identical
# True False   -> equal but NOT identical: two lists, same two songs
# True         -> 256 is on the shelf, so both calls fetch one object
# False True   -> 257 is one step past it: two objects, one value
# True False   -> one object, and it is not even equal to itself
↺ 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, where 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. Two objects, each pointing at the other. Now delete both names: del a, del b. Run the census. Object a still has one reference, sitting in b.next. Object b still has one, 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 this second 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; the cycle collector is 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 cycle▸ drag me
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, and 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, like lists, dicts, class instances, and 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 to keep 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. Plain refcounting then cleans up the instant the names go, and gc.collect() finds 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 →

SAY IT BACKthe chapter in five breaths
  1. seconds = 200 is three moves in a fixed order: the object is built first, the name is written into the namespace second, and = only clips the one onto the other.
  2. = never copies anything. b = a writes a second sticky note on the one object, which is why an edit made through b shows up under a.
  3. Every object is a whiteboard or a photo: mutate a list and its id() never moves, while "changing" a string or a tuple quietly builds a replacement and slides my label onto it.
  4. Each object keeps a count of the arrows pointing at it, and CPython frees it the instant that count hits zero — in the line that dropped the last one. del cuts a name, never an object, and the one shape this cannot see is a cycle, which is the only reason the gc module exists.
  5. The integers −5 through 256 are pre-built once and shared by the whole interpreter, which is why is and == agree on 200 by accident and disagree openly on 300.
You already owned the pieces: chapter 1 gave you = and the file that runs top to bottom; chapter 2 gave you the object, its type and its id() — this chapter only walked the arrow between the name and the object, and found the tally that decides when the object dies.
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
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked