03Names & memory
In Chapter 2 we learned that every value is an object — a thing that stands on its own in memory, carrying its own type and its own moves. This chapter asks the other half of the question: how does a name find that object? Most courses answer with a box that holds a value. That picture is wrong, and it's behind nearly every surprise Python has in store. Here's the plan. First we'll watch a name land on an object like a sticky note. Then we'll slap a second note on the same object and see what happens when one of them scribbles on it. We'll separate the values you can edit in place from the ones you can't. And we'll open up Python's memory manager while it runs a quiet label census, hands out small integers from a shelf it built before you woke up, and hits the one blind spot where that census fools itself — reference cycles. The whole way through, one question stays in front of us: when I change b, does a change too, and could I have known before I ran the line? By the end you'll answer that on sight, and you'll know exactly what the machine is doing to memory underneath. Queue Blinding Lights into Titanium; it's a seven-and-a-half-minute job, and operators come next.
01A name is a sticky note, not a box
Let's start with the simplest line in Python and refuse to be fooled by it. Type seconds = 200 — the running time of "Blinding Lights" — and hit Enter. It reads like an order to a stock-room clerk: put 200 into a box labelled seconds. But what actually happens is nothing like that. Read the line the way the interpreter runs it: right to left, in three separate moves. First, an int object holding 200 comes into being somewhere in memory, with no name attached to it. Second, the name seconds is written into a little table of names Python keeps for you — the namespace. Third, the = copies nothing into nothing. It binds the name to the object, the way you press a sticky note onto a jar already standing on the shelf. So the name and the object are two separate things that met a moment ago — and id(seconds) hands you the exact spot where they met.
Because a sticky note is only a note, it peels off clean. Write seconds = "3:20" and the very same name lifts off the int and lands on a brand-new str object — a different thing, at a different address, of a different type. Now look at what never changed type: the name. Names don't carry types; objects do. seconds was never "an integer variable" that later turned traitor. It was always just a label, and a label will stick to anything. That one fact is the whole of what people mean by dynamic typing: labels are typeless, retargetable, and very nearly free.
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.
seconds = 200, then a rebindinteractive= land the note, then rebind. The int is never found and the note is never copied — it simply points, then points elsewhere.= is the moment they connect. Rebinding moves the note, not the object — and orphans whatever the note just left.Now watch real Python agree with every frame of that figure. id() reports where the object lives, type() asks the object what it is, and sys.getsizeof() weighs it. After the rebind, all three answers change — because you are pointing at a different object, while the name itself stayed exactly what it always was: a label.
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 ownseconds = 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.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.seconds a million times just overwrites those 8 bytes a million times; not a single 200 is ever duplicated. The object carries its 28 bytes of weight; the name carries only an address. That gap is exactly why the next section — two names on one object — costs almost nothing.✗ The myth
seconds = 200 stores the number 200 inside the variable seconds, the way a jar holds jam. The value and the name are one thing.
✓ The reality
It stores the address of an int object that lives elsewhere in memory. The variable holds a direction, not a value — and the object it points to would exist, and keep its type, even if the name vanished.
The deeper cut — in what sense id() is "the address"
Saying "id(seconds) is the object's memory address" is a labelled simplification. It's dead accurate on the Python you're running, so we lean on it, but it's worth refining exactly once. On CPython (the interpreter you almost certainly have), id(x) returns the raw address of the object in memory. You can prove it the brutal way: take that integer, hand it to ctypes.cast(addr, ctypes.py_object).value, and Python reads 200 straight back out of that location. So on this machine, the meeting-point really is a street address.
But the language promises less than that. The spec only guarantees that id() returns an integer that is unique among currently-living objects and constant for one object's lifetime. It says nothing about that integer being an address. A different implementation (PyPy, Jython) is free to hand you an opaque ticket number that is not a location at all. So trust id() for the question it truly answers — "same object or not?", the whole basis of the next section — and treat "it's the address" as a CPython bonus, not a promise. And once the last note peels off an object and nothing points at it, as the int 200 does in step ④, CPython reclaims that memory on the spot by counting references — a mechanism we'll open up later in the chapter.
If a name is just a note pointing at an object, nothing stops you slapping a second note on the very same object. Two labels, one thing — and the moment they coexist, a question sharpens: when does that shared pointing help you, and when does it quietly bite? →
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.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.
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.)= and b = a stay instant on any object — they copy the note, never the jar.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.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.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.
= 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.b = a copies the label, not the listfigureb = a duplicates the label, never the list. The single gold box is the only object in play; a and b are just two arrows into it.There are two questions you can ask about any pair of names, and Python answers them with two different operators. == asks "do these hold equal contents?" — a reading of the values. is asks the sharper question: "are these the very same object?" Aliasing is precisely the case where a is b is True. Two lists typed out separately can be equal without being identical — same songs, but different objects. An alias, though, is identical: one object with two arrows. And id() makes it concrete. It returns the object's identity, which in CPython is literally its address in RAM — the house number from chapter 0. Matching addresses mean there was only ever one house.
ainteractiveb'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.)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:
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 safeAnd 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.
a is b (or id() of each) — a True means there was only ever one object, and your "copy" was an alias all along.=? 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.b = a as "make b like a." Read it as "point b at whatever a is pointing at." Once the equals sign means aiming an arrow instead of duplicating a thing, aliasing stops being a trap and becomes obvious: of course both arrows hit the same box — you only ever moved arrows, never built boxes.The deeper cut — shallow copies, and the += that fools everyone
Two refinements the tidy story above quietly simplifies. First, a.copy() is a shallow copy: it builds one new outer list, but the items inside are copied as arrows, not as fresh objects. So if a playlist holds other lists — say a = [["Blinding Lights", 200]] — then b = a.copy() gives you a new outer list whose single slot still aliases the same inner list. Run b[0].append("x") and a[0] changes too. To sever every layer you need copy.deepcopy(a), which we unpack in chapter 6.
Second, a genuinely sneaky one. For a list, b += ["Levels"] and b = b + ["Levels"] look like twins but are opposites. += mutates in place: it's the same object, so any alias sees the new song, and a is b stays True. But b + ["Levels"] builds a brand-new list, and b = rebinds b onto it, leaving a on the old one — so a is b becomes False. One character of difference decides whether your neighbour's playlist changes with yours.
b could reach back and change a only because a list will hand you the pencil. Try the very same edit on the number 200 or the text of a title and the object shrugs you off — some things simply refuse to be rewritten. Next: whiteboards you can scribble on, and photographs you cannot — mutable vs immutable →
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.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.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.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.03Whiteboards and photos: mutable vs immutable
Every object Python ever builds has one of two temperaments, and once you can tell them apart, half the surprises in the language stop being surprises. Some objects are whiteboards: you can walk up and rewrite them in place, and they stay the same board. Others are photos: fixed the instant they're taken, and editable only by shooting a new one. That single split — can this object be changed from the inside, or not? — is what the words mutable and immutable mean.
The membership list is short enough to memorise. The whiteboards are list, dict, set, and bytearray — the containers you build up and tear down as a program runs. The photos are int, float, str, tuple, bytes, and frozenset — the values you quote, compare, and pass around but never scribble on. The language even ships twins: bytes is the photo and bytearray the whiteboard of the exact same raw bytes; frozenset is the photo and set the whiteboard. Same data, opposite temperament.
Here's where it bites. Take our playlist. When you write title += " (Live)" it looks like you edited the string. But strings are photos, and you cannot edit a photo. So Python does the only thing it can: it builds a brand-new str object holding "Levels (Live)" and quietly moves your title label onto it. The old photo "Levels" isn't touched — it just sits there, un-pointed-at, waiting to be swept up. Now contrast a list's append, which genuinely rewrites the whiteboard: same object, same id, one row longer. The tell is exact. Mutate a whiteboard and its id is unchanged. "Change" a photo and you're suddenly holding an object with a different id, because it's a different object.
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.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.# 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 movedAnd here's the twist that catches everyone: the very same operator behaves oppositely on the two kinds. queue += ["Wake Me Up"] extends the list in place — same id, because a whiteboard can absorb the change. But title += " (Live)" has nowhere to land the change, so it rebinds title to a freshly built string. The syntax is identical, yet one mutates while the other replaces. The difference lives entirely in the object's temperament, never in the +=.
Now the memory trade-off snaps into focus, and it's a real one you'll feel. Photos cost a fresh allocation on every "change". Build a long string with += in a loop and you've quietly minted one throwaway object per step, each abandoned the instant the next is born. That's exactly why seasoned Python reaches for "".join(parts) instead. But photos come with a payoff: because nothing can ever alter one out from under you, they are perfectly safe to share. Whiteboards are the mirror image — edits are cheap and in place, but every label stuck to the board feels every stroke. And that, at last, explains the whole aliasing surprise from the last section: aliasing only bites on whiteboards. Two names on one list means one board and two hands that can write on it. Two names on one string means two people holding copies of a photo that can never change.
There's a second, quieter payoff to being a photo, and it's the reason two of Python's most-used containers even work. Because an immutable value is frozen for life, Python can take a permanent fingerprint of it — a hash — and trust that fingerprint forever. That fingerprint is the admission ticket to being a dict key or a set member. A whiteboard has no such stable fingerprint: mutate it after filing it away and every lookup would break, so Python simply refuses. Try it and you get TypeError: unhashable type: 'list'. This is why song titles (strings) make fine dict keys, and a (200, 245) tuple slots happily into a set, but a bare list never can.
# 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 fingerprintedqueue 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.title += " (Live)" work at all — and isn't rebuilding the whole thing every time wasteful? It works because += isn't editing the old object; it's building a new string and repointing the name. And yes, it's genuinely wasteful in a loop: gluing a thousand pieces together with += mints a thousand throwaway strings, each abandoned the moment the next appears. That's the whole reason "".join(parts) exists — it builds the result once, on a single whiteboard, instead of shooting a thousand photos.✗ The myth
"Immutable means the value can never change." So people assume a tuple is a rock — nothing inside it can ever move.
✓ The reality
Immutable means the object's own slots are frozen — you can't swap what each slot points at. But a slot can point at a whiteboard, and that can still be edited. Immutability is shallow: it locks the frame, not the world inside the picture.
The deeper cut — immutability is only skin-deep, and that's why some tuples can't be keys
Here's the subtlety the tuple hides. A tuple is a photo of which objects sit in which slots — nothing more. If a slot holds a list, the photo captures only that this slot points at that particular list; it says nothing about the list's contents. So pair = (["Blinding Lights"], 200) is a bona-fide immutable tuple, yet pair[0].append("Titanium") succeeds and mutates the list inside. The tuple's id never changes, because the slot still points at the same list — only the list's own contents moved. What you cannot do is pair[0] = [...]. Reassigning a slot is editing the tuple itself, and that raises TypeError.
This is also why "tuples are hashable" comes with an asterisk. A tuple computes its fingerprint by asking each element for its fingerprint. When the elements are all photos — (200, 245) — they give a stable hash, so the tuple is a fine dict key. But (["Blinding Lights"], 200) contains a whiteboard with no stable fingerprint, so hashing it raises unhashable type: 'list'. A tuple is hashable exactly when everything it holds is a photo, all the way down.
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. →
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.() 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.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.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.id or new id? one +=, two temperaments04Reference counting: the label census
Every object CPython builds carries a tiny header stamped in front of it, and the very first thing in that header is a plain integer. It answers one question, over and over: how many arrows are pointing at me right now? That integer is the object's reference count. An arrow — a reference — is anything that currently holds the object: a name you bound it to, a slot in a list, a value in a dict, an attribute hanging off another object, even the invisible argument slot while a function runs. Add an arrow and CPython nudges the counter up by one; take an arrow away and it nudges it down. Every way you will ever gain or lose a reference — and you'll meet dozens — pulls on this same little lever.
The counter exists to answer exactly one further question: can this memory be reused yet? The instant the count falls to zero, no arrow points at the object and nobody can ever reach it again. So CPython frees it on the spot, right there in the line that dropped the last reference. Not "sometime later," not "when memory runs low," not "when a collector wakes up." Immediately and deterministically. This is the quiet engine under everything you do in Python: a running census of arrows, and a funeral the moment the last one leaves.
3.Now read one operation that almost everyone misreads: del. When you write del track, you are not deleting the list. You are removing a label. You cut the track arrow, the counter drops by one, and that is all. If any other arrow still points at the list, it lives on, perfectly happy, now answering to a different name. The object dies only when its last arrow disappears. Rebinding does the same quiet arithmetic from the other side: x = other drops whatever x used to hold, so that object loses an arrow, and hands the new object one. Bindings and dels are just arrows being drawn and erased. The census does the rest.
Walk the playlist and watch the tally move. track = [...] creates the list with one arrow — count 1. alias = track aims a second name at the very same list — count 2, two labels on one object. del track erases one label — count back to 1, and the list is unbothered. del alias erases the last — count 0, and the memory is reclaimed in that instant. Slide through it below.
sys.getrefcount always reports one more than the true count — the next figure explains why.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."
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.)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 spottrack, 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.
class Track:
def __init__(self, name):
self.name = name
def __del__(self): # runs the instant the last reference drops
print("freeing", self.name)
t = Track("Blinding Lights")
a = t # two names, one object
del t # still alive -- 'a' holds it, count is 1
print("about to drop the last name")
del a # "freeing Blinding Lights" prints HERE, at once
print("...and only now do we get here")✗ The myth
"Python has a garbage collector that runs later, in the background, and eventually gets around to freeing things — like Java or Go."
✓ The reality
The main engine is reference counting, and it's immediate: the object is gone the microsecond its count hits zero, in the line you wrote. Python does also ship a separate collector, but it exists only to mop up one thing refcounting can't handle on its own — reference cycles. It's a backstop, not the main act.
The deeper cut — the one case where "freed at zero" isn't the whole story
"The count hits zero and the object is freed" is a labelled simplification. It's true for the overwhelming majority of objects, and we'll refine it exactly once, here. Consider a list that contains itself: a = []; a.append(a). Now two arrows point at that list — the name a, and slot 0 inside the list. Run del a and the name goes, but the internal slot still points home, so the count lands on 1, never zero. The list is now unreachable — no name anywhere can find it — yet refcounting alone will keep it alive forever, a little island of memory pointing only at itself. This is the leak that pure reference counting cannot fix.
So CPython runs a second, occasional cyclic garbage collector whose entire job is to hunt down these unreachable islands and break them. It's why import gc exists. Unlike refcounting, it is not deterministic — it runs every so often, not at a precise moment. That's exactly why the myth above is half-right: there is a lazy collector, it's just the sidekick, not the star.
And if you want to see the true count without the borrowed +1 that getrefcount adds, you can read the header field straight out of memory. ctypes.c_ssize_t.from_address(id(obj)).value reads the very first machine word of the object — ob_refcnt itself — and reports 1 for a single-named list where getrefcount insists on 2. That header, by the way, is why the smallest possible object isn't free. sys.getsizeof(object()) is 16 bytes on a 64-bit build — 8 for the reference count, 8 for the type pointer — before the object holds a single thing.
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. →
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.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.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.05The small-integer shelf
Here's a small confession about section 1. When you wrote seconds = 200, you pictured Python minting a brand-new integer for you — 28 fresh bytes, stamped 200, wrapped in a name. It did no such thing. Before your program ran a single line, CPython had already built every integer from −5 through 256 — 262 of them — and laid them out on a shelf that lives for the entire life of the interpreter. So 200 wasn't made; it was fetched. Your name got tied to an object that was already sitting there, pre-built, waiting.
Why those numbers? Because they're the workhorses. Loop counters climb through them. List indexes are them. Exit codes, boolean flags dressed as 0 and 1, the length of a song in seconds — the small integers show up constantly, in every program, in the interpreter's own guts. Minting a fresh 28-byte object every single time the digit 0 appears would be a storm of pointless allocation. So Python pre-pays exactly once, at startup, and then shares forever. And that answers the riddle you were left holding: your code's 2, an imported library's 2, and the 2 buried deep in the interpreter are not three twos. They are the one and only 2, wearing a crowd of different names.
= 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.2 in every program — yours, a library's, the interpreter's — is the same 2. Numbers from 257 up are not on the shelf.You can catch the shelf red-handed with id(), which hands you an object's address in memory: two names with the same id are two labels on one object. But there's a trap laid across your path. Type the literal 300 twice in the same chunk of code, and Python's compiler may quietly reuse a single object for both — folding equal constants before your program draws its first breath, and faking a shelf where none exists. int("300") slips past that trick: the string only becomes a number while the program runs, long after the compiler has gone home. So any sharing you then observe is the shelf's doing and nothing else. That one precaution is the whole reason the snippet below reaches for int("...") instead of a bare literal.
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 timesys.getrefcount(2) returns exactly 4294967295 — which is 2³² − 1, a sentinel value, not a census of who's using the 2. Since Python 3.12 the shelf integers are immortal: their reference-count field is pinned at that flag and never actually ticks. On older Pythons the same call returns a large, jittery real count instead (tens of thousands, environment-specific). Either way the message is the same — an enormous crowd shares this object — the deeper cut has the mechanism.Rather than take those two lines of output on faith, drive the boundary yourself. Slide the pointer along the number line below. Inside the shelf, both int("...") calls hand back the very same object, and id(a) == id(b) is True. Step one past 256 and the calls mint two separate objects — same value, different addresses — and the check flips to False. There is no gradual fade; there's a wall at −5 and a wall at 256.
2 in the whole interpreter is literally the same object, and I write x = 2 then x = x + 1, did I just mutate the shared 2 into a 3 for everyone else?No — and the reason is the quiet load-bearing fact under this whole section: integers are immutable. x + 1 never edits the object x points at. It computes a new value and hands back a different object — here, the shelf's pre-built 3. Then = re-aims the name x at that 3. The shared 2 is never touched; it just loses one of its many labels. Sharing is only safe because nobody can write through a name to change the object underneath. A shelf of mutable objects would be a catastrophe: one function's + 1 would silently corrupt every other 2 in the program.
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.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 →
−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 = 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.is and == accidentally agree on 200. There's a value where they violently disagree: x = float("nan"). Then x is x is True (it's one object) yet x == x is False (NaN equals nothing, not even itself). Same object, not equal — the mirror image of two shelf integers that are equal but need not be the same object. Proof, from both sides, that is and == ask different questions.06The blind spot: reference cycles
Refcounting, from the last section, lives by one iron rule: free at zero, and only at zero. Every object carries a tally of how many references point at it. The instant that tally hits zero, the memory is handed back — no pause, no sweep, no ceremony. It is fast, simple, and correct for almost everything you will ever write. Almost. There is exactly one shape it cannot see, and now you're going to build it on purpose.
Take our two canon tracks and make a circular playlist — the last song loops back to the first, the way a real player does when repeat is on. Write a.next = b, then b.next = a. Now you have two objects, each pointing at the other. Delete both names: del a, del b. Then run the census. Object a still has one reference — it's sitting in b.next. Object b still has one — it's sitting in a.next. Neither tally is zero, so refcounting frees neither. Yet no name in your program, no line of your code, can reach either object anymore. They are an unreachable island: two objects propping each other up in memory forever, while the refcounter stares at "1" on both and shrugs. Left alone, that is a genuine memory leak.
So CPython runs a backstop on top of refcounting: the cycle collector, exposed as the gc module. Periodically it goes hunting for exactly these islands — clumps of objects reachable only from each other — and frees them as a group. You almost never think about it; it fires on its own schedule while refcounting does the everyday work instantly. But you can force a hunt right now with gc.collect(), which returns the number of objects it reclaimed. Watch it find our island:
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 bothTwo objects created, two objects reclaimed. Refcounting did the everyday work for free, and the cycle collector was the one tool that could see past the "1 each" lie. To feel why it has to exist, look at the two questions being asked side by side.
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.
refs: 1 with no name attached — that is the leak refcounting alone can never close.1 into a 0.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.✗ The myth
"Python has a garbage collector, like Java — it's the thing that frees your memory." So people reach for gc.collect() whenever memory feels high.
✓ The reality
Refcounting frees the overwhelming majority of objects, instantly, the moment their count hits zero — no collector involved. The gc module is a narrow backstop for the one shape refcounting can't see: cycles. Most programs would run fine, just leakier, with it switched off.
The deeper cut — "refs: 1 forever" is a labelled simplification
Saying the island's counts sit at "1 each, forever" is a labelled simplification. It's true enough to see the leak, but here's the truth underneath. First, the collector doesn't just eyeball those counts. It walks the container objects it's tracking and subtracts the references that live inside the group itself. If an object's count drops to zero once you ignore all the internal, cycle-only arrows, then nothing outside the group holds it — so the whole clump is unreachable and can be freed together. That's how it tells a genuine island apart from a cycle still pinned by one live outside reference.
Second, the collector doesn't track everything. Only objects that can contain references — lists, dicts, class instances, tuples — can ever form a cycle, so those are all it watches. Atoms can't: gc.is_tracked(200) and gc.is_tracked("Titanium") are both False, while gc.is_tracked([]) is True. (CPython even untracks a container that provably holds only atoms — a fresh empty {} reports False — a small optimisation that keeps each sweep cheap.)
Third, you don't need cycles this elaborate. A single list that contains itself, L = []; L.append(L), is already a one-object island: del L leaves its count at 1, and only gc.collect() frees it. And the classic escape hatch is a weak reference. b.next = weakref.ref(a) points at a without bumping its count, so the back-edge never forms a real cycle, and plain refcounting cleans up the instant the names go — leaving gc.collect() nothing left to do.
One more myth worth killing, since older tutorials still repeat it: cycles containing objects with a __del__ finalizer used to be uncollectable, quarantined in gc.garbage. Since PEP 442 (Python 3.4) they're collected normally. On this machine, Python 3.12.7, a cycle whose objects define __del__ still reclaims cleanly and leaves gc.garbage empty.
Every object, you now know, carries two separate truths: its value (what it is) and its identity (which box in memory it is). The next chapter hands you the operators that interrogate each — == asks "same value?", is asks "same object?", and they don't always agree — plus the arithmetic where 7 / 3 flatly refuses to hand you an integer →
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..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.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.