python/volume-1/chapter-11ch 11 / 64 · 22 min

11Memory, mastered

In Chapter 10 we learned to stream — to let a generator hand us one item at a time, so we never had to hold the whole thing at once. This chapter turns to what we do hold. It asks the one question a profiler eventually forces on you: how many bytes did that object actually cost? Here's the plan. First we weigh things honestly. Python gives you a single number, and there's a far larger truth hiding behind it. Then we start cutting. We pack raw numbers wall-to-wall with array, delete a hidden table off every object with __slots__, hand a cache the power to let go with weakref, spend a little memory to buy speed with lru_cache, and finally page a file bigger than RAM through a sliver of it with memmap. By the end, we've taken Python's flexible-but-heavy defaults apart and bought back every byte we weren't using. No fatter machine, no less data — just knowing exactly what we're storing and what it costs.

First, weigh it Pack the numbers: array vs list Shrink every object: __slots__ The reference that lets go: weakref
scroll to begin

01First, weigh it

Chapter 3 in one breath: every object carries a reference count, and the instant that count hits zero the object is freed. Behind it runs a generational collector, sweeping up the reference cycles the counter alone can't see. All that machinery answers exactly one question: when does memory come back? Now we turn the other way. The question you'll actually feel in a profiler isn't when — it's how much did you hand over in the first place. Here's the catch: you cannot shrink a footprint you cannot read. So we start at the scale.

The scale is a single function — sys.getsizeof(obj). Point it at an object and it reports the bytes that object occupies. That's it. Point it at an integer and you get 28: the same 28-byte parcel you met back in chapter 2, a plain machine number wrapped in Python's object bookkeeping. Point it at a list of ten track lengths and you get 136. Two clean numbers. And almost everyone reads that second one wrong.

THE ONE IDEA TO CARRY FORWARD
getsizeof weighs one object, by itself — the parcel, never the things the parcel points at. A container's own bytes are the box, the packing tape, and a list of addresses. Whatever lives at those addresses is weighed separately, or not at all. Get this one distinction right and every surprising memory number in Python stops being surprising.

Read that 136 slowly, because it decomposes exactly: 136 = 56 bytes of list header + 10 pointers × 8 bytes. That is the container only. The ten pointers are just addresses — each one a signpost reading "the real number is over there." Over there sit ten separate int objects, 28 bytes apiece. That's 280 more bytes scattered across the heap, and getsizeof never mentioned them. The list didn't store your numbers. It stored directions to them.

weigh.pypython
import sys

# ten track lengths in milliseconds — exactly what a music API hands you
# (Blinding Lights = 200000, Titanium = 245000, then eight more)
lengths_ms = [200000, 245000, 238000, 194000, 260000,
              215000, 222000, 301000, 244000, 313000]

print(sys.getsizeof(200000))      # 28  — one boxed int, weighed alone
print(sys.getsizeof(lengths_ms))  # 136 — the list CONTAINER only
The scale reads the box, not the contentsfigure
lengths_ms — a list object holding ten track lengths header 56 B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B sys.getsizeof(lengths_ms) → 136 B = 56 + 10 × 8 int 200000 · 28 B int 245000 · 28 B …8 more · 8 × 28 B these 280 bytes are real — the scale simply never weighs them
Fig 01 — getsizeof weighs the container (136 B): a 56-byte header plus ten 8-byte pointers. The ten int objects those pointers lead to — 280 B in all — sit elsewhere on the heap, uncounted.
↺ reframe
Stop picturing getsizeof as a bathroom scale you stand your data on. Picture a postal scale that weighs exactly one parcel. Hand it a list and it weighs that single box — cardboard plus a slip of paper listing ten addresses. It does not, cannot, walk to those ten addresses and weigh what's there. The number is completely honest about the box. It just was never a measure of the shipment.
Drag the count — watch the scale undercountinteractive
the list object — what getsizeof puts on the scale hdr 56B 8B 8B 8B 8B 8B 8B 8B 8B 8B 8B the int objects — elsewhere on the heap, weighed by no one 28B 28B 28B 28B 28B 28B 28B 28B 28B 28B getsizeof reads 136 B true weight 416 B the scale hides 280 B — the weight of the numbers themselves
10
Drag it down. Each song you remove drops the green bar by 36 real bytes but shrinks the gold reading by only 8 — because every added number is a fresh 28-byte object the scale never sees. The two bars only meet at zero, on the bare 56-byte header.
Fig 02 — Gold is what getsizeof reports (56 + 8×N); green is the true weight (56 + 36×N). The widening gap between them is the memory the scale leaves out.
Wait — if those ten int objects genuinely exist and cost 280 bytes, isn't getsizeof lying to me by saying 136? Not at all — it answers the question it was asked, which is "how big is this one object." The list truly is 136 bytes. The confusion is ours: we point the scale at the container and read the number as if it covered the contents. A postal scale isn't dishonest because it won't tell you the weight of everything the address label points to.

✗ The myth

sys.getsizeof(my_list) tells me how much memory my list uses. Bigger data, bigger number — one call and I know my footprint.

✓ The reality

It weighs the container alone. For a real footprint you must walk the structure — follow every pointer, add each object's own getsizeof, and skip anything you've already seen so shared and cyclic objects aren't double-counted.
THE HONEST TOTAL IS A WALK, NOT A CALL
Python ships no built-in "deep size," so you write one: recurse into the list, add getsizeof of each element, and keep a set of id()s already counted. The id() guard is the load-bearing part — it stops you counting a shared object twice and stops a cycle from recursing forever. Run that walk on our list and it returns 416: the 136-byte container plus the 280 bytes of numbers the plain call left out.
The deeper cut — where 56 comes from, why append breaks the formula, and the cache that can make the 280 vanish

Three honest refinements to the tidy numbers above, so you're never caught out by a profiler.

Where the 56 comes from. Ask the list for its own accounting and [].__sizeof__() reports 40 bytes: the refcount, the type pointer, the length, the current capacity, and a pointer to the slot array. getsizeof then adds a 16-byte garbage-collector header, because the collector needs a handle on every container that could form a cycle. So 40 + 16 = 56. That header is the toll every GC-tracked object pays. It's exactly why getsizeof is a hair larger than an object's raw layout.

Why 56 + 8×N is only true for an exact-size list. Build a list all at once — a literal, or [0]*n — and its slot array holds precisely N pointers. But grow it with .append() and Python over-allocates: it grabs spare slots so the next append is free. On this machine the capacity jumps 0 → 4 → 8 → 16. So a list of 5 appended items and a list of 5 literal items can report different sizes. The formula measures the snug case. Real lists carry slack.

The cache that can make 280 disappear. We used lengths in milliseconds on purpose. CPython pre-builds every small integer from −5 to 256 as a single shared singleton — int(str(256)) is 256 is True, but int(str(257)) is 257 is False. Had we stored plain seconds — [200, 245, …] — those ints would already exist in the cache. The list's pointers would aim at objects Python built at startup, and a deep walk that dedupes by id() would add almost nothing for them. Our millisecond values are all far past 256, so each is its own fresh 28-byte object, and the 280 is entirely real. The lesson keeps recurring: the container's size is fixed and knowable, but the contents' cost depends on what's actually out there. Sometimes it's already been paid.

So ten numbers cost 416 bytes to store 40 bytes of actual number — a 90% tax, almost all of it pointers and per-int object headers. What if we threw the boxes away and packed the raw numbers wall-to-wall, four bytes each? That's the trade an array makes against a list — next. →

Wait — a list of the first million track lengths weighs about 8 MB, yet range(1_000_000) weighs 48 bytes — the very same 48 as a range spanning a quintillion, range(10**18). A range never stores its numbers; it keeps only start, stop and step and computes each one on demand. getsizeof is weighing the recipe, not the meal.
EMPTY IS NEVER FREE
The lightest object Python builds is None, at 16 bytes. An empty list is 56, an empty dict 64, and an empty set a startling 216 — thirteen Nones of pure bookkeeping before it holds a single element. getsizeof always counts the header, refcount and type pointer, so “empty” still weighs something.
A 90% TAX, AND WHERE IT GOES
Ten track lengths are only 40 bytes of actual number — four bytes each. Storing them costs 416: a 90% tax. Almost none of it is the list. 280 of those bytes are the ten int boxes, and each 28-byte box spends 24 bytes on refcount, type-pointer and length just to wrap four bytes of value. The tax is per-object overhead, paid once per number.
Wait — an empty string is 41 bytes and each ASCII letter adds just one, so "Titanium" costs 49. But a single emoji costs 64: one character outside the basic plane forces Python to widen every character in that string to four bytes and carry a fatter header. Same “one character,” wildly different weight — the scale even reflects which alphabet you typed in.
Drag n — one machine word overflows, one object just growsinteractive
one number — 2^n — held two ways. Drag n and watch who keeps telling the truth. the number: 2^64 C — uint64_t · a fixed 8-byte machine word (64 bits, full stop) 8 bytes · fixed forever 0 OVERFLOW Python — int object · grows one 30-bit limb at a time (never overflows) hdr 24B ↑ 4-byte limbs — one added per 30 bits of the value 36 bytes exact the cliff is 2^64
2^64
Python's int weighs 24 + 4×⌈(n+1)/30⌉ bytes — exactly what sys.getsizeof reports. A C word is a flat 8 bytes, but the instant n reaches 64 it runs out of bits and 2⁶⁴ silently becomes 0. Python answers the same question by allocating one more 4-byte limb for every 30 bits of value — that growing payload the chapter keeps weighing is the price of never lying about a number.
Fig — Gold is C's fixed 64-bit word; green is Python's variable-length int. Past 2⁶³ the gold word wraps to 0 (wrong) and never recovers; the green object just grows one 30-bit limb at a time, always exact. The extra bytes buy the one thing a machine word can't: an answer that's still true.
Trace3 step the machine — idea · code · memory move together
What a value really weighs — header + payload
Every Python value is an object: a fixed header of bookkeeping plus a payload that holds the value. sys.getsizeof adds those two and reports the total — for this one object only, never what its pointers lead to. Press Next and watch the payload grow while the header sits still.
int 28 Bempty list 56 B+8 B / element
The object on the scale — header vs payloadweigh
any Python valueobject
header · fixed bookkeeping payload · the value off the scale
HEADERbookkeeping
sys.getsizeof =?
 
getsizeof never crosses this object’s edge.
In plain words
Under the hood
SIZEOFALU · header + payload
Program · weigh.py
Memory · the byte accounting
registers — what the scale reports
off the scale objects getsizeof did not weigh
what's happening
beat 1 / 1

02Pack the numbers: array vs list

Back in chapter 6 you paid for a list's flexibility. Now you learn the receipt. Every slot in a list is a pointer — an 8-byte address that says only "the real thing lives over there." Store a playlist's track lengths in a list and each number costs you twice. There's the 8-byte slot to point at it, plus a fully boxed int object sitting elsewhere in RAM — header, refcount and all — weighing 28 bytes. That is 36 bytes to remember one number a pocket calculator holds in four. A thousand songs, and you've spent 36 KB pointing at numbers that would fit in 4.

The array module — already in the standard library, no install — makes the opposite bargain. Its whole promise is one word: homogeneous. Tell it up front that every element is the same kind of number, and it throws the boxes away entirely. array('i', …) stores raw machine integers — the bare 4-byte values the processor adds and compares directly, with no Python object wrapped around them — laid shoulder to shoulder in one unbroken block. That little 'i' is a typecode. It means "signed 4-byte int," and it fixes the width of every cell at itemsize = 4 bytes, for the life of the array. Same numbers, a ninth of the memory.

THE TRADE IN ONE LINE
A list stores pointers to numbers; an array stores the numbers. You hand back the freedom to mix types — ints, strings, other lists, all in one container — and in exchange you stop paying a pointer and a box for every single value. Flexibility you were not using, refunded as bytes.
Same six numbers, two layoutsfigure
list — evenly spaced 8-byte slots, each pointing at a boxed int elsewhere in RAM ptr · 8 B ptr · 8 B ptr · 8 B ptr · 8 B 200000int object · 28 B 245000int object · 28 B 238000int object · 28 B 194000int object · 28 B per number: 8 B slot + 28 B box = 36 B array('i') — the same numbers as raw 4-byte cells, packed wall to wall 200000 245000 238000 194000 260000 215000 per number: 4 B — no slot, no box, no header. getsizeof is the whole bill.
Fig 1 — The same six durations, two layouts: 36 B each — boxed and pointed at — versus 4 B each, packed. The array discards the one thing it does not need: the freedom to hold anything but an int.

Watch Python confirm the shape. A real music library — Spotify's API is one — keeps every duration in milliseconds. So "Blinding Lights" is 200000 and "Titanium" is 245000: numbers far too big to squeeze into a single byte, and each one genuinely its own object once it lands in a list. Pack them into an array instead and the whole thing becomes one flat ribbon of bytes. This is the same trick one scale up, and it's what makes audio possible at all. A waveform is nothing but a long run of integer samples — roughly 44,100 numbers for every second of sound — and no machine could hold a three-minute track as millions of separately boxed int objects. Packed wall-to-wall, a whole song is one clean block of bytes. In section 6 you'll page exactly such a block off disk without ever loading it whole.

pack.pypython
from array import array
import sys

# Spotify hands you every duration in milliseconds
lengths = array('i', [200000, 245000, 238000, 194000, 260000])
#                     ↑ Blinding Lights   ↑ Titanium

print(lengths.itemsize)        # 4   — bytes per cell, fixed for life
print(sys.getsizeof(lengths))  # 100 — header 80 + 5×4, the WHOLE bill
print(lengths[2])              # 238000 — still indexes like a list

lengths.append('quiet')          # TypeError: 'str' object cannot be
#                                    interpreted as an integer

Two things just happened that never happen with a list. First, sys.getsizeof told the whole truth — 100 bytes, nothing hiding — because there are no boxes stashed elsewhere to chase down and add up. (Ask a list its size and you get only its slot array. The 28-byte boxes it points at are not counted, and totalling them is on you.) Second, the flexibility is gone by design. An array('i') holds ints and only ints. Try to append a string and Python stops you cold with a TypeError — the cell is four raw bytes, with nowhere to put a word.

↻ reframe
Picture a list as the index at the back of a book — a neat column of page numbers, each sending you off to find the real thing. An array is the page itself, the words printed in place. The list never holds your data; it holds directions to your data. The array is the data, and that is the whole reason it is smaller.
Slide n — watch the list bill outgrow the array's toward exactly 9×interactive
n = 1,000 track lengths list of int objects 36.1 KB array('i') 4.1 KB the array is 8.8× smaller
1,000
list ≈ 56 + 36n bytes (8 B slot + 28 B box per number, and real lists keep spare slots on top); array('i') = 80 + 4n, and that is the entire bill. The ratio climbs toward exactly 9× as the fixed headers wash out.
Fig 2 — At ten items the fixed headers dominate and the win is modest; by a million, every number costs 36 bytes one way and 4 the other, and the gap locks at 9×.
THE TYPECODE IS THE WIDTH
That 'i' was a choice, not a law. The typecode picks how many bytes each cell gets: 'B' = 1 (0–255), 'h' = 2, 'i' = 4, 'q' = 8, and 'd' = 8 for floats. Measured in whole seconds, "Blinding Lights" (200) and "Titanium" (245) both fit in a single 'B' byte. Reach for the narrowest cell your numbers actually fit — and no narrower.
Step the typecode — set the width, see what fitsinteractive
array('i') — 4 bytes per number bytes per number holds  ±2.1 billion  ·  milliseconds — 200000 1,000 numbers → 4,080 bytes (80 + 1,000 × 4)
'i'
One array, one type. A narrower cell is smaller but holds a smaller range — drop below 'i' and a millisecond count overflows; step up to 'q' and you can store nanoseconds at 8 bytes each. The list stores any of these, but at a flat 36 bytes a number regardless.
This is NumPy's whole secret
Chapter 8's memory ranking — dict > list > NumPy > generator — is not a mystery once you have seen this. A NumPy array is exactly this raw-cells trick, industrialised: one typecode, values packed contiguously, zero boxes — then wired so the arithmetic runs on the bytes themselves. array is the stripped-down original that already ships with Python.
Wait — if an array('i') has no int objects inside it, only raw bytes, then what does lengths[2] actually hand you? A brand-new int, minted on the spot by reading those four bytes and wrapping them. A fresh object every read — lengths[2] is lengths[2] is False. The array pays a pinch of unboxing work each time you touch a cell; that is the toll for its wall-to-wall thinness.

✗ The myth

Python is just memory-hungry. Once the data gets big, your only moves are a fatter machine or less data.

✓ The reality

Python's defaults are flexible-but-heavy, and flexibility you are not using is pure waste. Every tool in this chapter refunds it: array packs numbers, __slots__ trims objects, generators stream (chapter 10), memmap pages from disk. You buy back the bytes by giving up freedom you never spent.

The deeper cut — when "36 bytes a number" is a lie, and why NumPy exists

That headline of 36 bytes per number quietly assumes every value is a distinct object. It isn't always so. Python caches the small integers from −5 to 256 and reuses them forever. So if your numbers happen to be tiny — the two songs in whole seconds, 200 and 245 — a list does not allocate a fresh 28-byte box per slot. Every 200 in that list is the very same shared object. You can see the seam: 200 is 200 is True, but int("200000") is int("200000") is False. This is exactly why we priced the library in milliseconds200000, 245000, all past the cache — where each length really is its own box and the 36 bytes are genuinely paid. Store small, repeated values and a list is far cheaper than the formula claims. Go the other way, past about a billion, and each int grows to 32 then 36 bytes, so the list gets heavier still while the array cell never budges from 4.

And the array's re-boxing from the curio isn't just trivia — it's the reason NumPy had to be written. Reading or writing one Python element unboxes and re-boxes an int: cheap once, but ruinous across a million values in a loop. NumPy sidesteps the toll completely. It runs the add, the compare, the sum in compiled C directly over the packed bytes, never building a Python object until you ask for a single element back. array gives you the tight storage. NumPy gives you tight storage and tight arithmetic on top of it. Same idea, two altitudes.

Arrays pack numbers. But a Song is not a number — it has a title, an artist, a length, each with a name. How do you shrink an object whose fields you look up by name? →

THE LIMIT IS EXACTLY 9, NOT “ABOUT 9”
As the fixed headers wash out, the list-to-array ratio doesn’t drift toward “roughly nine” — it converges on exactly 9. Every number is 36 bytes boxed-and-pointed (an 8-byte slot plus a 28-byte int) against 4 bytes packed raw, and 36 ÷ 4 = 9, dead on. The clean integer is no coincidence: both costs are constants per element.
Wait — if an array('i') holds no int objects, what is actually in the four bytes for Blinding Lights200000? The values 64, 13, 3, 0 — that’s 200000 written in base-256, lowest digit first: 64 + 13×256 + 3×65536 = 200000. No header, no refcount, no pointer — just the number’s own digits, laid down exactly where the list kept a signpost.
STILL LIGHTER: KEEP THE RECIPE
An array('i') packs a million track lengths into about 4 MB — but a generator that yields them holds just 192 bytes and stores no number at all: over 20,000× lighter. That is chapter 8’s ranking in one line — list > array/NumPy > generator. array wins when you must keep every value; a generator wins when you only pass through once.
Trace3 step the machine — idea · code · memory move together
A list of pointers vs a packed array — where the bytes go
A list [200000, 245000, 238000] is three pointers aimed at three separate boxed ints on the heap — 8 bytes to point, 28 to box, 36 per number. An array('i') throws the boxes away and packs the raw 4-byte values in one unbroken block — 4 per number. Same three numbers, 9× the weight. Press Next and watch each container spend.
3 numberslist 36 B/numarray 4 B/num
list vs array — where the bytes gosame 3 numbersmeet
list — 3 slots, each a pointer36 B / number
ptr · 8 B
ptr · 8 B
ptr · 8 B
200000int · 28 B
245000int · 28 B
238000int · 28 B
array('i') — one packed block4 B / number
2000004 B
2450004 B
2380004 B
list total
array total
In plain words
Under the hood
COUNTALU · the operation
Program · pack.py
variables · type
Memory · bytes each container spends
registers — the running tally
byte ledger value storage · list vs array
what's happening
beat 1 / 1

03Shrink every object: __slots__

Every section so far has watched Python trade memory for freedom, and every time the freedom turned out heavier than it looked. Here is the heaviest habit of all, and the one line that breaks it. You haven't built a class yet — that's chapter 12 — so here's just enough to stand on. A class is a stamp, an instance is one object stamped from it, and writing s.year = 2019 sets an attribute on that instance. Where does Python keep those attributes? In a per-instance __dict__ — a full hash table (chapter 7) mapping each attribute name to its value, one private table hanging off every object. That little dictionary is exactly what lets you bolt a new attribute onto any object, any time. It's also why one plain Song costs, on CPython 3.12 (64-bit), a 48-byte object plus the 296-byte dict it points at — 344 bytes of skeleton per song, before the title string has spent a single byte.

JUST ENOUGH CLASS FOR THIS CHAPTER
Three words this chapter leans on before chapter 12 builds them in full. A class is a stamp for making objects; an instance is one object stamped out; a method is a function that lives on the class and takes the instance as its first argument (self). That is the whole vocabulary __slots__, weakref, and __enter__/__exit__ need — the real machinery arrives next chapter.

__slots__ is the deal offered in return. Declare the complete list of attribute names up front and Python does something drastic. It deletes the per-instance dict entirely and reserves a fixed position — a slot — for each name, right inside the object. The same three values, but now they live in the instance instead of in a heavy table hanging off it. The 296-byte drawer simply never gets built.

THE TRADE IN ONE LINE
A plain object stores its attributes in a separate hash table it holds a pointer to. A slotted object stores them in fixed positions inside itself. Same data, one fewer heap object per instance — and multiplied across a million instances, that "one fewer" is the entire game.
slots.pypython
import sys

class PlainSong:                       # an ordinary class — chapter 12 builds these in full
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

class Song:
    __slots__ = ('title', 'artist', 'seconds')   # the COMPLETE attribute list, up front
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

p = PlainSong('Blinding Lights', 'The Weeknd', 200)
s = Song('Blinding Lights', 'The Weeknd', 200)

print(sys.getsizeof(p) + sys.getsizeof(p.__dict__))   # 344  = 48 + 296
print(sys.getsizeof(s))                               # 56   — and no __dict__ exists at all
print(hasattr(s, '__dict__'))                         # False

Run it and Python states the whole case in three numbers: 344, 56, False. Notice what the last line means: the dict didn't shrink, it vanished. A slotted Song has no __dict__ to ask about.

Where the attributes live — dict on the side vs. slots insidefigure
PlainSong — the instance points at a dict instance · 48 B __dict__ · 296 B 'title' → 'Blinding Lights' 'artist' → 'The Weeknd' 'seconds' → 200 48 + 296 = 344 B per song Song with __slots__ — values live inline Song instance · 56 B slot 0 · title → 'Blinding Lights' slot 1 · artist → 'The Weeknd' slot 2 · seconds → 200 56 B per song — the dict is gone
Fig 05 — __slots__ deletes the per-instance __dict__ and folds the three values into fixed offsets inside the object. The 344 B of skeleton collapses to 56 B — and every byte saved is a dict that was never allocated.
Wait — if the dict is such dead weight, why does Python hand one to every object by default? Because that drawer is the flexibility Python is famous for: monkey-patching a live object, setattr at runtime, bolting on a cached field, poking at vars(obj) to see what's inside. The default optimises for freedom. __slots__ lets you sell that freedom back — as bytes — precisely when you have millions of objects and you already know, exactly, what shape each one is.

One object saving 288 bytes is a rounding error you'd never feel. The trade only pays off at scale. Picture a music service holding its whole catalogue in memory at once. Slide the library size, then flip the switch and watch the total fall off a cliff.

ONE ALTITUDE DOWN: A CLASS PER FRAME
The catalogue is thousands of Song objects. Go one altitude lower and the temptation is a class per audio frame — and “Blinding Lights” alone is 8,820,000 of them. There the same arithmetic turns violent: 344 B of plain-object skeleton each would be ~2.8 GB of pure overhead before a single sample value is stored, while a slotted Frame at 56 B cuts that to ~470 MB. The real lesson underneath both: at 8.8 million samples you don't wrap each one in an object at all — you pack them raw in an array('h') at 2 bytes each (~17 MB). __slots__ is the fix when you must have objects; the array is the fix when you must not.
Toggle __slots__ across a whole catalogueinteractive
one Song object Song · 48 B __dict__ · 296 B one heap dict per object = 344 B each × 1,000,000 songs 344.0 MB tick “declare __slots__” to shrink every object
1,000,000
Plain: 344 B of skeleton per song. Slotted: 56 B. The dict was the heavy part — declaring __slots__ deletes it from every instance at once, and a million-song catalogue sheds 288 MB.
Fig 05b — The per-object saving is fixed at 288 B, so the bar shrinks in exact proportion to the catalogue. This is why __slots__ is a data-structures decision, not a micro-optimisation.
__SLOTS__ IS A CONTRACT, NOT A FREEBIE
You gave up the dict, so you gave up its superpower. s.year = 2019 now raises AttributeError: 'Song' object has no attribute 'year' — no name outside the declared list, ever. And a slotted instance cannot be weakly referenced (the very next section) unless you add '__weakref__' to the slots, which grows it back from 56 to 72 bytes. Slots buy you memory by spending flexibility; make that trade on purpose.
SLOTS DON'T INHERIT FOR FREE
Subclass a slotted class and forget to give the child its own __slots__, and Python quietly hands the child a __dict__ again — every saving evaporates, and the child can bolt on any attribute it likes. The rule is unforgiving: every class in the chain must declare __slots__ (an empty () counts) for the whole line to stay lean. A Remix(Song) with no slots is back to 72 B + a 296 B dict; the same class with __slots__ = ('dj',) stays at 64 B.
↺ reframe
Stop thinking of __dict__ as free. Every plain object carries an open drawer — a whole hash table — kept ready just in case you ever attach an attribute you never declared. __slots__ is you telling Python "I already know every attribute this object will ever hold," and Python rewarding that certainty by throwing the drawer away. Flexibility you don't use isn't free; it's memory you pay for on every single instance.

✗ The myth

__slots__ is mainly a way to lock a class down so nobody sprinkles stray attributes onto your objects — a tidiness feature, a linter's friend.

✓ The reality

The lock is a side effect. The point is that with no dict to hold arbitrary names, Python stores your values in fixed slots inside the object — so the memory the dict used to cost never gets allocated at all. The saving is unconditional and guaranteed; the "no new attributes" rule is just what makes it possible.

The deeper cut — the honest cost, the descriptor, and why access is a touch faster

Two claims above deserve refining. First, "one plain object plus a 296-byte dict": modern CPython already softens that. Since PEP 412, all instances of a class share a single keys table, so each instance's dict stores only the values. And since 3.11 the dict isn't even materialised until something touches it — until then, the values ride inline in the instance. The 296 B we measured is the real price once it exists, which for our __init__ is immediately. __slots__ still wins cleanly, because the win is unconditional: no dict, no keys table, no lazy anything — just three pointers in a row.

Second, "a fixed position inside the object" is literally true. Each name in __slots__ becomes a descriptor on the class — type(Song.title) is member_descriptor, printing as <member 'title' of 'Song' objects> — and it knows a byte offset into the instance. Reading s.title is "go to offset k," a straight fetch. Reading p.title on a plain object is "hash the string, probe the table." That's why slotted access is measurably (if slightly) faster, not just smaller, and the machinery is exactly the descriptors chapter 12 unpacks. One last measured fact for the mental model: an empty slotted object is 32 B, and each declared slot adds a flat 8 B. The plain object starts at 48 because it silently reserves two extra pointers, one for __dict__ and one for __weakref__ — the very drawer __slots__ refuses to build.

You can shrink an object right down to the bone — and it still costs those bytes forever if something refuses to let it die. The usual culprit is the very thing built to help: a cache clutching a reference long after everyone else has moved on. What if a reference could point at an object and still, when the time came, simply let go? →

SLOT SIZES ARE JUST ARITHMETIC
A slotted object with no fields at all is 32 bytes, and every name you declare adds exactly 8 more — one pointer, in a row. So a three-field Song is 32 + 3×8 = 56, predictable to the byte. A plain object starts heavier at 48 empty, because it silently reserves two extra pointers — for the __dict__ and __weakref__ it may never use.
Wait — __slots__ saves memory, but does reading an attribute get slower? The opposite. p.title on a plain object means “hash the string 'title', probe the dict, follow a pointer”; s.title on a slotted one means “go to offset 0, fetch.” Each slot is a member_descriptor that already knows its byte offset — so slotted access is smaller and a touch faster, for the same value.
SLOTS QUIETLY REVOKE THE NEXT TRICK
Deleting the dict also deletes the hidden __weakref__ slot, so a bare slotted Song cannot be weakly referencedweakref.ref(s) raises TypeError until you list '__weakref__' yourself, which regrows the object from 56 to 72 bytes. The very watchability the next section relies on is one of the freedoms __slots__ sells back for bytes.
Trace3 step the machine — idea · code · memory move together
Where an attribute lives — a drawer beside the object, or a slot inside it
Every plain object carries a private __dict__ — a whole hash table hanging off it, one per instance. Declare __slots__ and Python deletes that drawer and folds the values into fixed positions inside the object itself. Same three values; 344 B of skeleton collapses to 56 B. Press Next.
dict 296 Bslots 3×8 Bsaved 288 B
Two ways to hold three attributesCPython 3.12 · 64-bitalloc
PlainSongno __slots__
instance48 B
reserves a pointer → __dict__
__dict__296 B
'title'→ 'Blinding Lights'
'artist'→ 'The Weeknd'
'seconds'→ 200
'year'→ 2019
48 B
Song__slots__
instance56 B
values live inline — no drawer
slot 0title'Blinding Lights'
slot 1artist'The Weeknd'
slot 2seconds200
hasattr(s, '__dict__') → False
s.year = 2019 → AttributeError
56 B
plain344 B
slotted56 B
per-object skeleton — before the title spends a byte
In plain words
Under the hood
ALLOCALU · the operation
Program · slots.py
Memory · instance layout
registers — running state
the byte tally
what's happening
beat 1 / 1

04The reference that lets go: weakref

Back in chapter 3 you learned the one rule that governs every object's life. It lives for exactly as long as its reference count stays above zero, and the instant the count hits zero the bytes are handed back. That rule is a gift: automatic cleanup, no free() to forget. But it has a shadow. Any long-lived container that stores an object is itself a reference, so it keeps that object's count above zero for as long as the container lives. Build an album-art cache as a plain dict, drop "Blinding Lights" from the playlist, and the song's bytes still won't come back — the cache is still holding the count up. Nobody wants the object any more, yet it cannot die. That's a memory leak with the best of intentions.

A weak reference is the way out. It points at an object without counting — it adds 0 to the reference count. The object's life is decided by the strong references alone; the weak one just watches from the side of the road. weakref.WeakValueDictionary is a dict whose values are held this way. The moment the last strong reference to a value dies, that entry doesn't linger — it removes itself. The cache stops being a reason to stay alive and goes back to being what a cache should be: a convenience that never outstays its welcome.

THE ONE IDEA TO CARRY FORWARD
A strong reference keeps an object alive; a weak reference only watches it. Life is decided entirely by the strong count — a weak reference contributes nothing to it, and so can never be the thing that stops an object from dying.
Two arrows point in — only one moves the dialfigure
a reference either counts, or it doesn't Song('Blinding') the object · 48 bytes bl strong — counts +1 art_cache weak — adds 0 refcount 1 one strong ref
Fig 1 — Both bl and art_cache point at the same Song, but the dial reads 1, not 2. The blue arrow counts; the cyan one only spectates. Cut the blue arrow and the object is free to die — the cyan one won't hold it back.

In code the whole trick is one import and one dictionary. Watch the length of the cache drop to zero on its own, the instant the strong reference goes — no del art["Blinding Lights"], no cleanup pass, nothing. And it isn't that the entry polls for a dead value. A WeakValueDictionary stores each value as a weak reference carrying a callback. When the Song's strong count hits zero (chapter 3's one rule) and the object is freed, the interpreter fires that callback, which deletes the key. The same refcount-reaches-zero event that frees the object is what erases its entry — one mechanism, not two.

art_cache.pypython
import weakref

class Song:
    def __init__(self, title):
        self.title = title

art = weakref.WeakValueDictionary()
bl  = Song("Blinding Lights")
art["Blinding Lights"] = bl     # weak — adds 0 to the refcount

print(len(art))    # 1
del bl              # the last STRONG reference is gone → freed at once
print(len(art))    # 0 — the entry removed itself
Scrub the timeline — strong refs come and go, the cache only ever watchesinteractive
running line: art["Blinding Lights"] = bl Song('Blinding') the cached object strong refs 1 bl also art_cache ✓ cached weak — adds 0 bl holds the Song; the cache watches it weakly — one strong ref
strong refs = 1
Push the slider right through four moments: art[...] = bl, then also = bl, then del bl, then del also. The gold cache arrow never adds to the dial. Only when the last strong arrow is cut does the object die — and the entry evaporates the same instant, untouched by your hands.
Fig 2 — The weak arrow is a constant; the blue arrows are the whole story. Cut the last strong arrow and the gold box flips from ✓ cached to gone — that flip is the WeakValueDictionary's length falling 1 → 0 on its own. The cache follows the object's fate instead of dictating it — the exact inversion that turns a leak back into a cache.
Wait — if the weak reference never kept the Song alive, what on earth do you get back when you reach for it after the Song has died? Not a crash, and not a stale, half-freed object — Python is stricter than that.

The raw form makes the answer plain. weakref.ref(bl) is a callable watcher. Call it and it hands you the object while the object lives, and hands you None the moment it's gone. There is no dangling pointer to trip over — the watcher knows its target has died and simply reports the honest truth.

watcher.pypython
t = Song("Titanium")
r = weakref.ref(t)      # a small, fixed-size watcher — ~80 bytes, whatever t's size
r()                     # → the Song, while it lives
del t                   # last strong ref gone
r()                     # → None — the watcher tells you it's gone

The WeakValueDictionary just wraps that behaviour in dict clothing. After the value dies, the key quietly ceases to exist, and art.get("Blinding Lights", "MISS") returns "MISS" — exactly as if you'd never stored it. The absence is the feature.

THE SAME TRICK, ON THE HEAVY VERSION
A Song is bytes; a decoded waveform is megabytes. Pack “Blinding Lights” as array('h') and its 8,820,000 samples are ~17 MB at 2 bytes each — the same run held as a Python list of boxed ints would be ~300 MB. Cache those decoded clips — each a small object owning its array('h') buffer — in a WeakValueDictionary keyed by title, and each one lives only while something is actually playing it; the instant the last strong reference drops, its 17 MB is freed and the key vanishes. The cache never pins a whole catalogue of audio it isn't using — the accidental hoard weakref exists to prevent, measured in the units that hurt.
Not everything can be watched
A weak reference needs somewhere to hang its watcher, and the leanest built-in types simply don't carry that slot. int, str, tuple — and, perhaps surprisingly, list and dict — all raise TypeError: cannot create weak reference. What can be watched: instances of your own classes, functions, and set. And if you slim a class with __slots__, you knock out the watcher slot too — you must add '__weakref__' to the slots by hand to earn it back.
↺ reframe
Stop thinking of a weak reference as a weaker way to hold something. It isn't holding at all. It's a subscription to an object's death — a way to keep a pointer that expires itself the instant the object stops mattering to everyone else. The cache doesn't decide when to forget; the object's own life tells it when.

✗ The myth

"A weak reference frees the object — you use weakref to delete things when you're done with them."

✓ The reality

A weak reference never frees anything and never deletes anything. Freeing is still done the ordinary way — the strong count reaching zero. All weakref changes is that this particular reference isn't one of the ones propping that count up.

The deeper cut — "freed at once" is a CPython promise, and where the watcher slot lives

Saying the object is freed the instant the last strong reference dies is a labelled simplification — dead-on for the Python you're running here, and worth refining once. This immediacy is a property of CPython's reference counting. The interpreter you have (3.12.7) drops the object the moment its count hits zero, deterministically. The language makes no such promise. On PyPy or Jython there is no per-object counter — objects are reclaimed whenever their garbage collector next runs — so there, a WeakValueDictionary entry can linger a while after its value goes unreferenced. And even on CPython, an object caught in a reference cycle (it points at something that points back at it) can't be counted to zero; it waits for the cyclic collector. So the crisp rule is: CPython, no cycles, freed at once.

Where does the watcher actually live? Every weak-referenceable object carries a hidden __weakref__ slot — a pointer to the list of weak references aimed at it. When the object dies, each watcher can be flipped to None and each callback fired. The lean built-ins (int, str, list, dict) omit that slot deliberately: you may hold millions of them, and one extra pointer each is a tax not worth paying. Your own classes include it by default, which is exactly why they can be watched and a bare __slots__ class cannot.

Two neighbours round out the toolkit. WeakSet is a set that forgets members as they die. WeakKeyDictionary holds its keys weakly — perfect for bolting extra metadata onto objects you don't own without keeping them alive. And weakref.ref(obj, callback) lets you run a function the moment the object dies (a real one fired in testing). One last honesty note: sys.getrefcount(x) always reads one higher than you'd expect, because you hand x to the function as an argument, and that temporary reference is counted too.

weakref stops the accidental hoard — memory you meant to release. But sometimes hoarding is the entire plan: keep the answers you paid for, and hand them back for free. Spend bytes on purpose, buy back time →

THE WATCHER IS A FIXED 80 BYTES
A weak reference to a three-field Song and a weak reference to a 100,000-element list are the same size: 80 bytes. Because the watcher doesn’t hold the object, it never scales with it — it is a fixed-size subscription to that object’s death, whatever the object weighs. weakref.ref(bl) is small precisely because it is neither a copy nor a container.
Wait — ask an object its reference count with sys.getrefcount(bl) and the answer is always one higher than you counted. Handing bl to the function is a reference — counted for the instant the call runs. A brand-new Song with a single name bound to it reads 2, not 1. Even measuring the count nudges it: a tiny observer effect, right there in the bookkeeping.
WHAT REFUSES TO BE WATCHED
You cannot weakly reference an int, str, tuple — and, surprisingly, not a list or dict either: all raise TypeError: cannot create weak reference. The lean built-ins omit the __weakref__ slot on purpose, since you may hold millions and one extra pointer each is a tax. What can be watched: your own classes, functions, and the odd ones out — set and frozenset.
A DEATH YOU CAN SUBSCRIBE TO
weakref.ref(bl, on_death) runs a function the instant bl is freed — turning “this object is gone” into an event you handle. Its cousin WeakKeyDictionary holds its keys weakly, so you can bolt metadata onto objects you don’t own and have every note evaporate the moment the object does. Bookkeeping that cleans up after itself, because it never propped anything up.
Trace3 step the machine — idea · code · memory move together
A reference that doesn't keep it alive — strong counts, weak only watches
A plain name is a strong reference: it bumps the refcount and that non‑zero count keeps the object alive. weakref.ref(obj) adds 0 — it just watches. When the last strong ref goes, the object is freed at once and calling the weak ref returns None. Press Next and watch the dial fall to 0.
strong +1weak +0dies → None
Two arrows point in — only the solid one moves the dialweakref.refstrong ref
refcount 1 strong refs only Song('Titanium') alive · 48 bytes strong · counts +1 t r weak · adds 0
strong — keeps it aliveweak — only watches
r() returns— (not called)
In plain words
Under the hood
OPALU · the operation
Program · watcher.py
names · in scope
Memory · the object & its count
registers — the running state
Song str title · the watched object
what's happening
beat 1 / 1

05Spend bytes, buy speed: lru_cache

Every trick in this chapter so far has spent one resource to save the other — packing, trimming, and evicting all spend a little time to save memory. This section flips the trade clean around: spend memory to buy speed. The setup is a function that gets asked the same question over and over. Being a diligent machine, it works out the same answer from scratch every single time. Turning 200000 ms into the label "3:20" is cheap. But picture the body doing real work — a database lookup, a network round-trip, a slow calculation — with the same input handed to it a thousand times, and paid for in full on every call. The deeper witness makes it concrete. That same “Blinding Lights” is not just its 200-second duration but 8,820,000 signed-16-bit samples on disk, and taking the FFT energy of one fixed window of them is exactly that kind of expensive work — asked for again, unchanged, every time a spectrogram redraws.

Memoization is the fix, and it's almost embarrassingly simple. Keep a note of every answer you have already worked out, filed under the arguments that produced it, and glance at the note before doing the work again. functools.lru_cache bolts exactly that onto any function with a single line. That @lru_cache(...) line is a decorator, and decorators are pure chapter 9: a function is just an object you can hand around. Strip the @ sugar and the line means one plain assignment — as_clock = lru_cache(maxsize=128)(as_clock). Call lru_cache, hand it your function, and rebind the name to the wrapped version it returns. That wrapper checks a hidden dict of past answers first, and only calls your original code on the way to filling that dict.

THE ONE IDEA TO CARRY FORWARD
A cache is nothing more exotic than a dict from arguments to answers. A hit — the arguments are already a key — hands back the stored answer for the price of one hash lookup, and never runs your function at all. A miss runs it once, files the result, and pays full price only that first time. You are buying speed with bytes.
as_clock.pypython
from functools import lru_cache

@lru_cache(maxsize=128)          # keep the 128 most-recent answers
def as_clock(ms):
    seconds = ms // 1000                          # 200000 ms → 200 s
    return f"{seconds // 60}:{seconds % 60:02d}"  # 185 s → '3:05', never '3:5'

as_clock(200000)             # miss: body runs, answer filed under 200000
as_clock(200000)             # hit:  served from the cache, body never runs
print(as_clock.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)

Read that last line like an instrument panel. The first call was a miss (misses=1): no note existed, so the body ran and the answer '3:20' was stored. The second call was a hit (hits=1): the arguments matched a stored key, so the answer came straight back and the body stayed cold. currsize=1 because both calls share one entry, and maxsize=128 is the ceiling we set. Call it three times, ten times, a thousand times with 200000 — the body runs exactly once, and every call after is a free hit.

A call meets the cache: miss pays, hit is freefigure
as_clock(200000) args seen before? no · miss run the body 200000 → '3:20' file it in the cache keyed by 200000 · up to 128 yes · hit return '3:20' a hit skips every red-and-gold box above
Fig 05 — A miss runs the body once and files the answer; a hit re-enters at the bottom and returns the stored answer for nothing. Memory traded for time, with the bill capped at maxsize.

First, why cap it at all? Leave the cache unbounded (maxsize=None) and a memoized hot function turns into a slow leak. Every never-before-seen argument files one more entry that is never removed, so on an endless stream of distinct inputs the cache quietly swallows your RAM. The bound is the cure, and it forces the question the two letters in the name answer. LRU is least recently used: when the 128 slots are full and a new answer needs a home, which old answer gets thrown out? The rule is to evict the entry that has gone untouched the longest — the least recently used one. The bet is that an answer you keep asking for is worth keeping, while one nobody has wanted in ages can be recomputed if it's ever missed again. Every hit quietly promotes its entry back to "most recent," so popular answers survive and stragglers fall off the end. The memory bill never climbs past maxsize entries. Step through it:

Five calls into a cache with only two slotsinteractive
one cache, maxsize = 2 — only two answers fit at once incoming call as_clock(200000) "Blinding Lights" · 200 s MISS body runs, answer stored the cache MOST recently used LEAST recently used — evicted first Blinding Lights key 200000 '3:20' — empty — nothing evicted — the cache had room hits 0 · misses 1 · currsize 1
call 1 of 5
Five calls, only two slots. Watch call 3 (a hit) promote "Blinding Lights" back to most-recent — so call 4 evicts "Titanium" instead of it, and call 5 has to recompute the Titanium it just threw away. That reshuffling is the whole meaning of least recently used.
Fig 05b — A hit isn't just a fast answer — it changes the future by promoting its entry, which changes who gets evicted next. A too-small cache thrashes: it keeps paying to recompute things it only just discarded.
↻ reframe
There is no magic in the @. The line @lru_cache(maxsize=128) is two ordinary steps: Python runs lru_cache(maxsize=128), which returns a wrapper-maker; that maker is handed your as_clock and returns a new function; and the name as_clock is rebound to point at the new one. The "hidden dict of past answers" is a plain dict living in that wrapper's closure. Your original function is untouched and still reachable as as_clock.__wrapped__ — the decorator didn't rewrite it, it wrapped a doorman around it.

On as_clock the win is real but modest, because the body was cheap to begin with. This was the clearest place to see the mechanism, not to feel the payoff. Point the same one line at a function that genuinely hurts and the trade turns spectacular. Naïve recursive Fibonacci is the classic: fib(32) re-derives the same sub-problems about seven million times. Memoize it and each n is computed exactly once:

fib.pypython
from functools import lru_cache

@lru_cache(maxsize=None)         # None = unbounded: never evict
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

fib(32)                          # fills the cache in ~0.02 ms
fib(32)                          # a pure hit: ~0.0003 ms
print(fib.cache_info())
# CacheInfo(hits=31, misses=33, maxsize=None, currsize=33)

Without the cache, fib(32) grinds for a few hundred milliseconds. With it, the whole thing fills in a fraction of a millisecond, and every repeat is a hit measured in ten-thousandths — thousands of times faster, bought with 33 stored integers. Notice the mechanism honestly, though. The cache here isn't only remembering the final answer, it's remembering every sub-problem along the way, and that's what collapses exponential recomputation into a single linear sweep. currsize=33 is precisely the n values 0 through 32, each computed once.

TWO RULES OF THE DEAL
Arguments must be hashable, because they become dict keys inside the cache — so as_clock([200000]) with a list raises TypeError: unhashable type: 'list'. And cache only pure functions, where the same arguments always mean the same answer. Memoize something that depends on the clock, a random draw, or a database that changes under you, and every caller after the first is served a fossil — the right answer to a question that has since moved on.
UNBOUNDED IS THE LEAK YOU JUST LEARNED TO FEAR
maxsize=None (and its shorthand @cache) means the cache never evicts — every distinct argument you ever pass is remembered forever. On a function called with a handful of repeating inputs that's perfect; on one fed an endless stream of fresh arguments it is exactly the ever-growing memory leak the previous section warned about, just wearing a helpful hat. Bound it with a real maxsize, or reclaim the whole cache on demand with as_clock.cache_clear().
Wait — if the cache keys on the arguments, are as_clock(200000) and as_clock(ms=200000) the same key? They are not. The cache keys on the shape of the call, not on where the values end up: a positional 200000 and a keyword ms=200000 hash to two different keys, produce two misses, and store two identical copies of '3:20'. Pick one calling convention for a hot function and stick to it, or you'll quietly pay every miss twice.

✗ The myth

"Slap @lru_cache on any slow function and it gets faster." So caching is a free speed knob you can turn anywhere.

✓ The reality

It only helps a function that is pure, takes hashable arguments, and is actually called again with the same arguments. Wrap a function called once per distinct input and you've added a growing dict and a hash on every call — pure overhead, and a slow memory leak, for zero hits.

The deeper cut — typed keys, the @cache shorthand, and slipping past the doorman

By default the cache treats numbers that compare equal as the same key, so as_clock(200000) and as_clock(200000.0) collapse into one entry — they're == and hash-identical. Pass typed=True and the cache keeps the int and the float apart, at the cost of a second stored copy. You want that only when the type genuinely changes the answer.

Since Python 3.9 there's a shorthand: functools.cache is literally lru_cache(maxsize=None) — same unbounded cache, less to type. And when you truly need the uncached value once — a fresh read, a benchmark of the raw body — call as_clock.__wrapped__(200000). That reaches the original function directly, skips the cache entirely, and doesn't nudge the hits or misses counters. The wrapped object also carries the tools you've been using all along: cache_info() to read the panel, and cache_clear() to wipe it.

Packing, trimming, evicting, caching — every game in this chapter has been played inside RAM, on data that fits. But some data is simply bigger than every box on the street, and every byte you cache is a byte someone eventually has to clean up. What happens the day the data won't fit at all — and who frees the memory you're done with, on time? →

THE SAME CLIP, ONE ALTITUDE DOWN
The playlist holds “Blinding Lights” as a title and 200 seconds — human metadata. The metal holds it as 8,820,000 signed-16-bit samples, the first eight being array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000]) — each sample 2 bytes, range -32768..32767, peak 24000. An FFT bin over a fixed window of those samples is genuinely slow, and a spectrogram asks for the same window every redraw, so @lru_cache(maxsize=128) keyed by window index computes each bin once and serves every later redraw as a free hit. Same object as the duration — one altitude lower, where the bytes are.
33 INTEGERS ERASE SEVEN MILLION CALLS
Naïve fib(32) calls itself 7,049,155 times, re-deriving the same sub-answers endlessly. One @lru_cache line collapses that to 33 calls — one per value 0 through 32 — because each sub-answer is remembered the first time it’s computed. Thirty-three stored integers stand in for seven million recomputations: exponential work flattened to linear.
Wait — are as_clock(200000) and as_clock(ms=200000) the same cached entry? They are not. The cache keys on the shape of the call, not on where the value lands: a positional 200000 and a keyword ms=200000 hash to two different keys, miss separately, and store two identical copies of '3:20'. Pick one calling style for a hot function, or quietly pay every miss twice.
THE LEAK YOU JUST LEARNED TO FEAR
@lru_cache(maxsize=None) — and its shorthand @cache, which is literally that — never evicts: every distinct argument is remembered forever. On a function fed a few repeating inputs, perfect. On one fed an endless stream of fresh ones, it is exactly the ever-growing hoard the weakref section warned about, wearing a helpful hat. Bound it with a real maxsize, or wipe it with as_clock.cache_clear().
Trace3 step the machine — idea · code · memory move together
Compute once, remember — @lru_cache on fib
The wrapper keeps a dict from arguments to answers. The first time each n appears it is a MISS — the body runs and the result is stored. Ask for that same n again and it is a HIT — served from the dict, the body skipped. Watch the cache fill on fib(4), then get reused. Shown for fib(4) so it fits the frame; fib(32) is the same shape at scale.
MISS fillsHIT reusesfib(4)
the cache — a miss fills it, a hit reads itdict · args ↦ answer
fib(4)
probe →
MISS — not in cache
run the body
fib(n-1) + fib(n-2)
HIT — already stored
return the answer
one dict lookup
cache dict · n ↦ fib(n)0 / 5 filled
fib(0)·
fib(1)·
fib(2)·
fib(3)·
fib(4)·
In plain words
Under the hood
PROBEALU · cache check
Program · fib.py
cache_info
Memory · the cache dict
registers — the running tally
cache dict · every n computed once, then reused
{}
what's happening
beat 1 / 1

06Bigger than RAM, cleaned up on time

Here is a size that should scare you a little. The numbers on the slider for "Blinding Lights" — the length, the title — are a handful of bytes. But the song itself, the actual waveform, is 200 s × 44,100 samples per second = 8,820,000 samples. Store each sample as a float64 (8 bytes of precision) and that's 70.56 MB for one track. "Titanium" at 245 s is 86.4 MB. Load a twenty-song playlist the obvious way and you've asked for well over a gigabyte of RAM — for audio you're going to stream past your ears a few thousand samples at a time and never need all at once. The naive move drowns the machine.

numpy.memmap is the escape hatch, and it's a genuinely surprising one: it hands you an array that lives on disk and pretends to be in memory. Here is what actually happens, and it is not a copy. Opening a memmap maps the file's bytes into your process's address space. The array's indices become addresses that translate straight to positions in the file, and at open time nothing is loaded. Only when you touch an address that isn't yet resident does the CPU trip a page fault, and the operating system reads in exactly the one 4,096-byte page that holds it — the smallest parcel the OS will ever move between disk and memory. Read wave[0] and the OS faults in that 4 KB slab; the other 70.55 MB stays on the platter, untouched, costing you nothing. The mapping is address translation, not a load.

levels.pypython
import numpy as np                     # pip install numpy

wave = np.memmap("levels.dat",         # 70.56 MB on disk, ~0 in RAM
                 dtype="float64",
                 mode="w+",
                 shape=(8_820_000,))    # 200 s × 44,100 per second
wave[0:3] = [0.0, 0.12, 0.25]          # touching index 0 pages in bytes 0–4095
wave.flush()                           # push buffered writes down to the file
del wave                               # release the mapping when you're done
The array on disk; RAM holds only the pages you touchfigure
RAM — only what you touched page 0 page 1 the slice you indexed wave[0:3] levels.dat — 70.56 MB on disk · 8,820,000 float64 samples · 17,227 pages index it like an array — the OS faults in only the 4 KB pages you actually read or write
Fig 1 — memmap: the array lives on disk as a strip of pages; RAM holds only the ones you touched. The other 17,225 pages never leave the disk.

Drag the reader below and watch the cost appear. The slider says how many seconds of the waveform you scan; the green bar is what that forces the OS to copy into RAM. Notice two things. The file on disk never grows, and RAM fills in page-sized steps — ask for a single sample and you still pay for a whole 4 KB page, because 4 KB is the smallest thing the OS knows how to move.

Scan more of the waveform — watch RAM fill one page at a timeinteractive
the bar is the whole file · green = resident in RAM · dark = still on disk RAM disk wave[0 : 44100] → 87 pages faulted in 356 KB resident in RAM levels.dat on disk: 70.56 MB — unchanged, whatever you touch
1 s
Slide to 1 s: a few hundred KB in RAM against a 70 MB file. Slide to 200 s: now the whole thing is resident — memmap didn't save memory by magic, it just refused to spend it until you asked.
Fig 2 — RAM is a cache over the file. The green front advances in 4 KB jumps; the file on disk is the same 70.56 MB the whole time.
THE ARRAY THAT WEIGHS 144 BYTES
Ask Python how big the wave object is with sys.getsizeof(wave) and it answers 144 bytes — while wave.nbytes reports the full 70,560,000. Both are true. The array's size is a promise about the file, not a slab of memory it grabbed. The shape is addressable; it is not allocated.
Wait — if I loop over the whole memmap, haven't I just loaded 70 MB into RAM the slow way? For that instant, some of it, yes. But the OS also evicts pages you've stopped using, so a 4 GB file can stream through 100 MB of RAM: pages fault in ahead of you and quietly drop out behind you. The win was never "less data" — it's that you never need all of it resident at once.

But look again at the code and you'll spot two chores that arrays don't usually demand: flush(), then del. A plain list never asks that of you. A memmap does, because it is holding something borrowed — an open file, a live mapping into the kernel. Files, mappings, sockets, database connections: these are resources, on loan from the operating system, and the loan sits outside the garbage collector's tidy world of Python objects. The GC frees memory, but it does not reliably flush your buffered bytes to disk. And here is the trap: if your program raises an exception on the line between acquiring the resource and releasing it, the cleanup line never runs at all.

Python's answer is the context manager — any object with two methods. __enter__ runs the moment a with block opens, and __exit__ runs when the block ends no matter how it ends. Normal fall-through, an early return, a raised exception — every path out of the block passes through __exit__. That is the whole guarantee, and it is what makes cleanup un-skippable.

save.pypython
with open("playlist.txt", "w") as f:      # __enter__ → f is the open file
    f.write("Blinding Lights — The Weeknd — 200\n")
    f.write("Titanium — David Guetta ft. Sia — 245\n")
# leaving the block ran __exit__ → f is GUARANTEED closed,
# even if a write() in the middle had raised an error
Step through one run of a with blockinteractive
with acquire("levels.dat") as r: __enter__ → resource r acquired your block runs — writing samples 💥 a line raises (ValueError) __exit__ → flushed & closed. Always. a clean run skips step 3 — and still lands on step 4
0 · with … as r:
Slide through one run. Step 3 is optional — a body that finishes cleanly skips it entirely — but step 4, __exit__, fires on both paths. That "always" is the entire reason with exists.
Fig 3 — Acquire in __enter__, release in __exit__, on every path out of the block — including the one through an exception.
THE BUG THIS KILLS
An unflushed memmap or an unclosed file means buffered bytes silently never reach the disk — the program "worked," exited 0, and the file on disk is wrong or truncated. Never trust a cleanup line placed after risky code; one exception jumps clean over it. Put the resource in a with and the release cannot be skipped.
A PAGE IS 512 SAMPLES
One 4,096-byte page holds exactly 4096 / 8 = 512 float64 samples — about 11.6 ms of audio at 44,100 Hz. Touch one sample, get 512. That's why sequential scans of a memmap are fast and random pokes are slow: the OS is always moving 512-sample slabs, whether you wanted the whole slab or not.

✗ The myth

memmap "loads the file into memory really fast," so a 70 MB file becomes a 70 MB array in RAM the instant you open it.

✓ The reality

Nothing loads until you touch it. Opening the mapping costs almost no RAM; touch all 8,820,000 samples and you pay for all 70.56 MB. It trades eager loading for lazy paging — not for free space.

The deeper cut — the exact __exit__ contract, and where "guaranteed" stops

__exit__(self, exc_type, exc_val, exc_tb) is handed three arguments: the exception's class, the exception object, and its traceback — or three Nones on a clean exit. Return True and the exception is swallowed on the spot. Return None or False (the default) and it propagates onward after your cleanup has run. That two-method protocol is the entire specification: any class that implements __enter__ and __exit__ can drive a with statement, including one you write to flush and close a memmap. (A raw numpy.memmap is not itself a context manager — hasattr(wave, "__enter__") is False — which is exactly why the code above flushes and deletes by hand.)

Two honesty notes on the word "guaranteed." First, __exit__ runs on every path Python controls — but a kill -9, a segfault in a C extension, or the power cord leaving the wall runs nothing. Durability past a crash is a stronger promise that needs fsync and careful ordering. Second, "outside the garbage collector's reach" is a labelled simplification: CPython's reference counter often does close a file promptly the instant its last reference drops, so sloppy code frequently gets away with it. But you must never rely on that. A reference cycle, or a different interpreter like PyPy, can delay the close arbitrarily, and by then the buffered write may already be lost. with turns "usually cleaned up" into "cleaned up, deterministically, now."

Every trick in this chapter has been quietly leaning on a word we haven't earned yet: class. Slots on a class, weak references to instances, __enter__ and __exit__ methods on a blueprint. It's time to build the real thing — a Song, a Playlist, and objects from the metal up →

Wait — read wave[0], a single 8-byte sample, and the OS still copies 4,096 bytes into RAM — a whole page, 512 samples, about 11.6 ms of Blinding Lights. You can’t fault in less than a page, so asking for one number hands you 511 neighbours for free. That is exactly why scanning a memmap in order is fast and random pokes are slow: the OS only ever moves 512-sample slabs.
↻ reframe
Stop picturing memmap as “loading the file fast.” Nothing loads. Picture RAM as a sliding window the OS drags across a file on disk: pages fault in just ahead of where you’re reading and drop out just behind you. A 4 GB waveform can stream through 100 MB of RAM because the file never has to fit — only the sliver you’re looking through does.
THE OBVIOUS with DOESN’T EXIST
You’d reach for with np.memmap(...) as wave: — but a raw memmap has no __enter__ (hasattr(wave, "__enter__") is False), which is precisely why the code flushes and deletes by hand. The with guarantee only exists for objects that implement the two-method __enter__/__exit__ protocol; a memmap simply isn’t one, so its cleanup is on you.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 11, in working code

Memory is the resource you feel last and fix hardest. This chapter watches the exact moves that make Python frugal: caching an answer, shrinking an object's shape, streaming instead of storing, weighing bytes, and reusing the one object you already have.

Cache the answer
Memoization trades a little memory for a lot of time. A dict remembers what you already computed; the second visit is a HIT that runs no work at all.
The shape of an object
Every ordinary instance carries a whole dict of its attributes — flexible, but heavy. __slots__ swaps that dict for a fixed row of slots, and the savings scale with millions of objects.
Stream, don't store
The cheapest list is the one you never build. Generators and itertools hand you one item at a time, so peak memory stays flat no matter how long the sequence — and reusing a value beats recomputing it.
Weigh the bytes
You can't optimize what you don't measure. sys.getsizeof shows the true cost of an object — and reveals why immutables pack tighter and why raw arrays crush lists of boxed ints.
One object, reused
Python quietly shares immutable objects so you don't pay for duplicates. Knowing when two names point at the SAME object — and when they don't — is the difference between is and ==.
end of chapter 11 · six sections
iolinked.com
Written by Ajai Raj