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

14The page you keep open

In Chapter 13 we taught Python what + should mean on objects of our own making. Thirteen chapters before that, all we had was a single bit. This last one isn't a lesson. It's the page you keep open after the lessons stop. Here's the plan. You get every value type on one honest line, every structure in one decision table, the fifteen idioms that carry most programs, and twenty-four words pinned down tight enough to kill eight myths that outlive most beginners. All the way through, we keep asking the one question that earns its keep: when you reach for a list, a dict, a generator, what are you really paying, and in what currency? By the end we fold all of it into six steps that turn the playlist into a working Playlist class — one that knows "Blinding Lights" (200s) sorts ahead of "Titanium" (245s). You walk out able to build without us, straight into Volume 2.

Every value, one line each The decision table Fifteen idioms that carry most programs Twenty-four words — and eight myths
scroll to begin

01Every value, one line each

Let's start by throwing almost everything away. Fourteen chapters, and the whole skeleton stands on just three facts. Hold these and you can re-derive the rest at a whiteboard. One: in CPython — the interpreter you're actually running — every value is a boxed object on the heap. Not the bare number 200, but a little parcel wrapped around it. First a reference count: how many names point at this box. It is kept for one blunt reason — the instant it drops to zero, the last name is gone, and CPython frees the box on the spot. Then a type pointer: what this thing is, and therefore what it can do. And only then the payload itself. Two: names are references. A name never holds the value. It holds the address of the box, the way a contact holds a phone number, not the person. Three: containers differ chiefly in how they find a thing. Mutability, and what's allowed to be a key, fall out behind that. And finding splits just two ways. The first is by computed position: list[i] is base + i×stride — one piece of address arithmetic, one probe, O(1), never a walk (exactly ch0's numbered boxes). The second is by hash: rather than scan slot after slot for a value you can't locate (O(n), unbearable at a million items), you fold the key down to a bucket number and jump straight there. Every other thing you learned — slicing, dunder methods, comprehensions, is versus == — is one of these three facts wearing a costume.

THE ONE IDEA TO CARRY FORWARD
A value is a box, a name is a reference to a box, and a container is a rule for finding boxes. Hold those three and you can re-derive the rest of Python at the whiteboard. Lose them and no amount of memorised syntax will save you — you'll guess, and the guesses will be wrong in the same three places every time.

Here are the atomic values — the ones that aren't made of other values — each on a single honest line. Read the last column as a litmus test: the one experiment that pins the type down when your memory blanks.

TypeOne lineMutable?Litmus
inta whole number; Python grows the digits as far as you need — no overflow, evernosys.getsizeof(200)28
floata 64-bit IEEE decimal — fast, and quietly imprecise past 15-odd digitsno200.0 weighs 24 B
complexa pair of floats; the imaginary part is written j, never ino(1+2j).imag2.0
boolTrue / False — a relabelled 1 and 0; the falsy ones are '', 0, 0.0, None, empty collectionsnobool("False") is True
strimmutable text; every "edit" mints a brand-new string elsewhereno"Titanium"
bytesimmutable raw octets, each just a number 0–255 — the very switch-patterns ch0 began with; the tag most MP3s open withnob"ID3" → the bytes 73, 68, 51 the CPU also reads as the letters I D 3
list / dict / setthe mutable containers — whiteboards you rewrite in placeyesthe decision table, next
tuple / frozensettheir frozen twins — photographs, and therefore hashablenousable as dict keys
Nonethe absence of a value; what a bare return hands backtest with is None, never ==

Notice that these sizes aren't intuitive. The number 200 costs 28 bytes — fourteen times the two bytes its value alone would need — because you're paying for the box, not the digit. Line those boxes up smallest to largest and the whole space story of a computer fits on one rung-ladder.

From one bit to a million-key dictfigure
bit 0 or 1 1 bit ×8 byte 8 bits 1 B boxed int 200 header + digit 28 B 5 refs list of 5 refs + slack 104 B ×1,000,000 dict · 1M keys hash table 40.0 MiB each figure is the object's own box — never the boxes it points at
Fig 01 — The whole space story on one rung-ladder, measured on this very interpreter: 1 bit → an 8-bit byte → a 28-byte boxed int → a 104-byte five-slot list → a million-key dict whose hash table alone is 40.0 MiB. getsizeof weighs the box, not its contents — the million int values that dict references are counted nowhere in that number.

The ladder hides the deepest of the three facts, so let's crack a box open. Whatever type you pick — a whole number, a song title, a whole playlist — the layout underneath is the same three-part parcel: a refcount, a type pointer, and a payload. Only two things change: the tag on the box, and the way you reach into the payload. Step the slider through all ten and watch that sameness hold.

The value inspector — one box, ten costumesinteractive
one value on the heap — the same three-part box, whatever the type int FROZEN refcount ≥ 1 type → int payload 200 atom · no lookup getsizeof → 28 bytes a whole number — Python grows the digits as far as you need
int
Step through all ten. The three compartments never change — a refcount, a type pointer, a payload. Only two things move: the mutable/frozen tag on the box, and how you reach the payload — an atom needs no lookup, text and lists are reached by computed position (base + i×stride, one probe — not a walk), dicts and sets jump by hash.
Fig 02 — Every Python value is this box. Change the type and you swap the tag and the reach-in rule; the parcel around it is identical. That sameness is the object model.
ASK THE INTERPRETER, NOT YOUR MEMORY
Four calls settle any argument about a value. type(x) — what is it. id(x) — which box (its address). sys.getsizeof(x) — how heavy is its own box. dir(x) — what can it do (an int answers with 74 methods, a str with 81). Ten seconds at the REPL beats an hour of arguing from memory — and the REPL is never wrong.

Watch all three facts fall out of six lines. One object, two names, a mutation seen through both, and a hashability check that draws the container line:

interrogate.pypython
import sys

song  = ["Blinding Lights", 200]   # a fresh object, born on the heap
alias = song                       # alias copies the reference, not the list

print(type(song), sys.getsizeof(song))   # <class 'list'> 72
print(song is alias, song == alias)      # True True — one box, so identical
alias.append("The Weeknd")               # poke it through alias…
print(song)                              # ['Blinding Lights', 200, 'The Weeknd']

print(hash((200, 245)))                  # a real int — a tuple is hashable, so key-able
hash([200, 245])                         # TypeError: unhashable type: 'list'
Wait — if a name is only a reference and 200 is a boxed object, does a = 200; b = 200 build one box or two? One. CPython keeps a permanent cache of the small integers from −5 through 256, so both names point at the same pre-made 200 and a is b is True. Ask for 257 twice at separate prompts and you may get two different boxes — the cache stops at 256. The values you reach for most are quietly shared, never copied.
↺ reframe
You didn't finish this course to memorise syntax — you finished it to install a memory model. Forget a method name and dir() returns it in ten seconds. Forget that names are references, that values are boxed objects, that containers find by position or by hash — and every cheat sheet on earth leaves you stranded, because the bug isn't in a name you can look up. It's in the picture.

✗ The myth

"A variable is a box, and assignment drops the value inside it. Copy the variable and you copy the value."

✓ The reality

A variable is a name tag; the box lives elsewhere on the heap. Assignment ties the tag to a box — it never copies the box. That's the whole reason alias = song lets one append show up under both names: two tags, one box.

The deeper cut — what's really inside those 28 bytes, and one honest asterisk on the sizes

Prise open the int 200 and the 28 bytes account for themselves exactly. Two machine words are pure header: an 8-byte refcount and an 8-byte type pointer. That is precisely what a bare object() weighs — 16 bytes, nothing but the header. Add an 8-byte length field and a single 4-byte digit to hold the value, and you land on 28. That header is the tax every value pays for the privilege of being a self-describing object. It's why 200 costs fourteen times its raw value.

Now two labelled simplifications from above, made honest. First, getsizeof weighs only the object's own box, never what it references. So the million-key dict reporting 40.0 MiB is just the hash table. The million int boxes it points at live elsewhere and aren't in that figure, so the true footprint is far larger. Second, that "refcount" compartment isn't always a live counter. None, True, and the small ints are immortal (PEP 683): their refcount is pinned at the maximum 4294967295 and never touched, because objects that outlive every program shouldn't waste cycles being counted. And a footnote to the table: bool is a genuine subclass of int, which is why True + True is 2 and True == 1 is True — the name tag is new, the box underneath is an integer.

Values are the atoms. Every real program then asks the second question — list, dict, set, or tuple? One decision table answers it for good →

Wait — the number 200 needs just one byte of value, but its Python box weighs 28 — you pay 27 bytes of overhead for one byte of number. Strip the value away entirely and a bare object() still weighs 16 bytes: pure header, nothing inside. The box, never the digit, is what you carry.
SOME OBJECTS ARE IMMORTAL
None, True and every small int from −5 to 256 have their reference count frozen at 4294967295 — that is 2³² − 1, the maximum — and it is never touched. An object that outlives every program you will ever run should not waste one cycle being counted (PEP 683). Check it: sys.getrefcount(None) comes back pinned at the ceiling.
Wait — bool is not its own thing — it is a genuine subclass of int. True is just 1 wearing a name tag, so True + True is 2, sum([True, True, False]) counts your Trues, and True == 1 is True. The tag is new; the box underneath is an integer.
NO SUCH THING AS OVERFLOW
Most languages cap an integer and wrap around at the edge; Python just grows the digits. 2**1000 is a real, exact number here — its box swells to 160 bytes to hold it, and 10**100 fits in 72. There is no INT_MAX to crash into: the box stretches until the value fits, or you run out of RAM. Drop back to a single raw byte, though — a bytes slot, 0–255 — and the old ceiling returns: 200 + 245 = 445, past 255, overflows one byte — the exact pressure that forced ch0's multi-byte teams. Python's int is the box that refused that ceiling.
Trace3 step the machine — idea · code · memory move together
Ask any value what it is — type(v), one type per beat
Fig 02 cracked the box open; this one puts its type pointer to work. Six atomic values, six honest lines — and for each, type(v) follows that one pointer to the class object and reads the label, never touching the payload. That is the whole move: a box knows what it is because it carries a pointer to its type, not because Python squints at the digits. Watch the answer land on its card, with the one-line identity and the box's own weight beside it. Sizes are the figures this CPython build reports.
6 typestype() reads the pointersize = the box
The value inspector — type(v) lights one cardask
200 type( ) <class 'int'>
FROZEN atom · no lookup getsizeof → 28 B
In plain words
Under the hood
TYPEALU · the operation
Program · ask_the_value.py
Memory · the value's own box
registers — what type() reported
value box the same 3-part parcel, whatever the type
what's happening
beat 1 / 1

02The decision table

You have met all six now, one chapter at a time. Lined up shoulder to shoulder, they hand you a chooser that fits in a single breath. Need your songs in order and reachable by position? A list. A tiny record that must never change, and might itself become a key? A tuple. Want to look a value up by name, instantly, a million times an hour? A dict. Only ever asking "have I seen this before?" A set. Shoving and pulling at both ends of a queue? A deque. A flood of raw numbers where every byte is money? An array. Six questions, six answers. But the reason each answer is right lives in one table, and that table is worth carrying for the rest of your working life.

Here is what the table is really telling you: every row is a promise about time and space. A data structure is not a bag that holds your stuff. It is a bet about which single operation you will perform most, paid for in memory you don't get back. Find the row whose cheap column matches the move you'll make ten thousand times, and everything else falls into place. So let's read the table properly, cell by cell.

Six structures, five promises eachfigure
structure get one item add at end front ± x in s memory shape list O(1) O(1)★ O(n) O(n) pointers → boxed ints tuple O(1) frozen O(n) a list minus the slack dict O(1)★ O(1)★ O(1)★ hash table · heaviest set no index O(1)★ O(1)★ keys only, no values deque O(1) ends O(1) O(1) O(n) linked blocks, both ends array O(1) O(1)★ O(n) O(n) packed raw C values green = one jump, any size red = a scan that grows with n ★ = amortized / average · see the deeper cut
Fig 04 — The whole map on one page. Read a row left to right and you are reading a personality: dict buys instant lookup and pays in memory; deque is the only one cheap at both ends; array is the featherweight that refuses to grow at the front. No structure wins every column — that is the entire point.
HOW TO READ A CELL
O(1) means the cost is the same whether the pile holds ten items or ten million — the machine computes where to go and jumps there, exactly like the addressed boxes of chapter 0. O(n) means the cost grows in step with the pile — it has to walk the items to find your answer. The little is an honest asterisk: "usually O(1), with a rare bad day." We refine it at the bottom of this section.

Reading rows is one skill. Choosing one under pressure is another. So drive the slider below through six real jobs off the playlist and watch the table collapse to a single lit answer each time. Notice how little of the row you actually consult. You look at the one column the job hammers, and the winner is obvious.

Pick the job — the structure lights upinteractive
the job 1 / 6 — look up a song’s length by its title, a million times a day list tuple dict set deque array dict → get O(1)★ · x in O(1)★ · memory: hash table hash the title, jump straight to its bucket — no scanning, no shifting.
1 / 6
Six jobs, six winners — each time, one column of the table decides it. The structure the job hammers is the one whose matching cell is green.
Fig 04b — Choosing is not memorising the whole grid. It is naming the operation you'll repeat, then picking the row that makes that operation cheap.
Wait — if a dict lookup is O(1) and a list search is O(n), why isn't everything a dict? Look one figure ahead: those instant buckets are bought with megabytes. A hash table refuses to get more than about two-thirds full — so collisions stay rare — which means it always hauls around a third of its slots deliberately empty. Speed and space are a trade, always, everywhere.

That trade is not hand-waving; you can weigh it. Build the exact same million integers four different ways, and watch the scale tip. Slide n from a thousand up to a million and the bars grow together. The one exception is the generator, which stays a sliver, because it never actually builds the million at all.

What a million numbers actually costsinteractive
building 1,000,000 integers — bytes actually allocated (tracemalloc peak) dict 81.3 MiB list 38.6 MiB NumPy 7.6 MiB generator 192 B — it never builds the million bars scaled to the dict · sizes are the measured 1M point, scaled per item
1,000,000
The ranking never flinches: dict > list > NumPy > generator, at every n. Watch the generator’s bar melt as the others climb — laziness is the only line that doesn’t pay per item.
Fig 04c — The memory pecking order to keep for life. Instant lookup (dict) is the priciest; packed raw values (NumPy) undercut the list about five-fold and the dict about ten-fold; a generator is essentially free because it computes each number just in time and forgets it.
THE FOUR MEMORY LEVERS
When the profiler screams, chapter 11 handed you four moves and this figure is their scoreboard: reach for an array instead of a list (raw values, no boxing — the NumPy bar); __slots__ instead of a per-instance __dict__ (drop the hidden hash table on every object); a generator instead of a built list (lazy, a flat couple hundred bytes); and memmap when the data outgrows RAM entirely and has to live on the SSD.
↻ a structure is a frozen decision
You are not really choosing a container — you are choosing, ahead of time, which operation gets to be free. Pay for lookup and you get a dict; pay for order and you get a list; refuse to pay for anything but bytes and you get an array. The table isn’t a menu of things. It is a menu of regrets you’re willing to accept.

✗ The myth

The fastest structure is the best one, so reach for whatever has the most O(1)s — a dict — and use it everywhere.

✓ The reality

The most O(1)s is also the most bytes and the most bookkeeping. A dict of a million ints weighs twice a list of the same numbers and ten times a NumPy array. You choose by the one operation you repeat, not by the longest row of green.

The deeper cut — the five asterisks the table rounded off

The grid tells the truth, but it rounds the corners. Here are the exact edges, each one verified in a Python shell:

1 · Append is amortized O(1). Most appends drop into pre-bought slack, so they cost nothing. Once in a while the list outgrows its block, allocates a bigger one, and copies everything across — a single O(n) jolt. Spread that rare jolt over the thousands of cheap appends around it and the average is still constant. Same story for the ★ on a growing array and on dict resizes: memory grows in jumps, not a smooth ramp. That is why the bar chart above is a clean model, not the jagged truth.

The bar chart above drew memory as a smooth ramp, but the prose just warned you that this is a clean model, not the jagged truth. Here is the truth. Watch what a single list.append actually costs, one call at a time. Almost every append is free: it drops into slack the list bought in advance. Every so often the block fills, and one unlucky append has to allocate a bigger block and copy every element across — a genuine O(n) jolt. The trick of “amortized O(1)” is not that the jolts never happen. It is how staggeringly rare they are.

The jagged truth of list.appendinteractive
cost of each append while growing one list to 1,000,000 items · both axes log cost of that append (element-moves) 1,000,000 1 1 n = 1,000,000 86 reallocations — the only appends that actually move the block, out of all 1,000,000. a gold spike = one reallocation: the block moves house, an O(n) jolt that instant real average: 8.4 moves per append — flat as n grows naïve “copy-all” model: 500 billion moves — 59,205× the real work
1,000,000
Slide n from ten to a million. Nearly every append lands in pre-bought slack and costs a single move — the flat green floor. Only at a gold spike does the block overflow and copy everything across: a real O(n) jolt, reaching right up toward the red line that assumes every append pays that price. Count the spikes — a million appends trigger just a few dozen, because each new block is bought about an eighth larger, so they arrive geometrically rarer. Rare tall jolts, averaged over the cheap appends between them, are all “amortized O(1)” ever claimed — never that no single append is slow.
Fig 04d — The asterisk on append, drawn. The green line is the honest headline — a small, flat cost that ignores the size of the list — and the gold towers are the price it quietly pays: a handful of full copies, spaced ever wider apart, that the average absorbs without a bump.

2 · Dict and set are O(1) average, O(n) worst case. If every key happened to hash into the same bucket, a lookup would degrade to a linear scan. To stop an attacker engineering exactly that, CPython salts string hashing with a per-process random seed. Run hash("Blinding Lights") in two fresh interpreters and you get two different numbers (fix PYTHONHASHSEED and they line up again).

3 · The small-int cache (−5…256) is a CPython implementation detail, not a language promise. int("256") is int("256") is True because both fetch the one cached object; int("257") is int("257") is False because each builds a fresh one. Never let is do the job of ==.

4 · sorted() is Timsort, and Timsort is stable: equal keys keep their original order. So sort the playlist by title first, then by artist, and inside each artist block the songs stay title-ordered for free. That's two cheap passes instead of one clever comparator.

5 · sys.getsizeof is shallow. It measures the container’s own box, never what the box points at. A three-element list holding three million-character strings reports just 80 bytes, because the list only stores three pointers; the three megabytes of text live elsewhere. To weigh a whole object graph you need the deep walk, not this number.

THE FIVE-SECOND CHOICE
Name the operation you’ll do ten thousand times. Lookup by key → dict. Membership → set. Order with an append → list. Both ends → deque. Immutable record → tuple. Raw numbers, tight memory → array. That’s the whole chapter in one reflex.

You can now pick a structure in five seconds flat. Next: thirteen chapters of hard-won habits, pressed into fifteen idioms you’ll type for the rest of your life →

Wait — if a dict lookup is O(1) and a list search is O(n), why isn't everything a dict? Because a hash table refuses to get more than about two-thirds full — so collisions stay rare — which means it always hauls around at least a third of its slots deliberately empty. A million-key dict's table alone is 40.0 MiB. Speed is bought with space, every time.
WHAT O(1) REALLY PROMISES
O(1) means the machine computes where to go and jumps there — the same single probe whether the dict holds ten keys or ten million, exactly like the addressed boxes of chapter 0. O(n) means it has to walk. That one difference — jump versus walk — is the whole personality every row of the table is describing.
Wait — range(10**6) weighs the same 48 bytes as range(10) — because it stores a rule (start, stop, step), not a million numbers. A generator over a million items is 192 bytes, flat. Laziness is the one thing in Python that doesn't pay per item: it computes each value just in time and forgets it.
A TUPLE IS A LIST WITH THE SLACK CUT OFF
(200, 245) is 56 bytes; [200, 245] is 72. A list keeps spare slots so append stays cheap — it is built to grow. A tuple can never grow, so it refuses to pay for room it will never use. Frozen buys you lean.
Trace3 step the machine — idea · code · memory move together
One playlist, three shapes — list, dict, set
The same three songs can be held three ways: a list of ordered, numbered slots; a dict of key→value buckets; a set of keys only. Press Next and watch how each one is found — the list by a position it computes (base + i×stride, one probe, never a walk), the dict and set by a key they hash. Mind which cost is being clocked: this is list indexing (O(1)) — searching a list for an unknown value is the separate O(n) walk.
3 songsindex vs hashlist index O(1) · search O(n) · dict/set O(1)
Same 3 songs · three shapesoverview
list
ordered slots
0"Levels"
1"Titanium"
2"Faded"
dict
key → value
"Levels"340
"Titanium"245
"Faded"212
set
keys only · no order
"Levels"
"Titanium"
"Faded"
lookup
Same three songs — a list, a dict, a set. Each answers a different question.
In plain words
Under the hood
SHAPEALU · how it's found
Program · three_shapes.py
names · type
Memory · which shape answered
registers — the running state
what's happening
beat 1 / 1

03Fifteen idioms that carry most programs

An idiom is a decision someone already made well, frozen into a phrase. Not a trick, not clever code to show off. It's the opposite. It's the shortest way to say a thing so ordinary that the language grew a groove for it, and everyone who reads Python reads the groove at a glance. You could always write the long way round; an idiom is the community agreeing you shouldn't have to. Learn these fifteen and you'll recognise most of the everyday Python you'll ever meet, because most everyday Python is these fifteen, rearranged.

Here they are, running — on the playlist we've carried since page one, now four songs long so the loops have something to bite on:

idioms.pypython
playlist = [
    {"title": "Blinding Lights", "artist": "The Weeknd",           "seconds": 200},
    {"title": "Titanium",        "artist": "David Guetta ft. Sia", "seconds": 245},
    {"title": "One More Time",    "artist": "Daft Punk",           "seconds": 320},
    {"title": "Strobe",          "artist": "deadmau5",            "seconds": 634},
]

# a 1-based menu — no manual counter, no off-by-one
for n, song in enumerate(playlist, start=1):
    print(f"{n}. {song['title']} — {song['artist']}")

titles  = [s["title"] for s in playlist]                        # build a new list
short   = [s["title"] for s in playlist if s["seconds"] < 300]  # filter: the test rides AFTER the for
by_len  = sorted(playlist, key=lambda s: s["seconds"])          # new sorted list; playlist untouched
artists = {s["artist"] for s in playlist}                        # set comp — duplicates fall out for free
length  = dict(zip(titles, [s["seconds"] for s in playlist]))   # two parallel lists → one dict

with open("playlist.txt", "w") as f:                            # the file closes itself, even mid-crash
    for s in playlist:
        f.write(f"{s['title']}|{s['seconds']}\n")

Run that top to bottom and the menu prints 1. Blinding Lights — The Weeknd down to 4. Strobe — deadmau5. Then short comes out ['Blinding Lights', 'Titanium'], artists is a four-name set, and playlist.txt is written and closed. Not one i = 0, not one manual counter, not one f.close(). That absence is the idiom. Let's put the single richest line under a microscope — the comprehension — because once you can read one, you can read the family.

Anatomy of a comprehension — three parts, in a fixed orderfigure
[ s["title"] for s in playlist if s["seconds"] < 300 ] what to keep the loop — the source of s the filter — always AFTER the for [ "short" if s["seconds"] < 300 else "long" for s in playlist ] a ternary rides before the for — it isn't a filter, it's part of "what to keep"
Fig 3a — One anatomy, two placements that beginners swap by accident. A filter (if … with no else) drops items and lives after the for. A ternary (… if … else …) chooses a value and lives before the for, inside the expression. Wrong slot, wrong meaning.

A diagram is frozen, though, and a comprehension is a verb. So run it by hand. Below, the recipe on the left is short from the code above — keep the title of every song under 300 seconds. Step the slider and watch Python do exactly what you'd do with a highlighter. It walks the list top to bottom, holds each song against the test, and drops the survivors into a brand-new list on the right. Nothing clever happens. That's the point.

Step the comprehension — one item at a time through the filterinteractive
short = [ s["title"] for s in playlist if s["seconds"] < 300 ] playlist — walked top to bottom kept — a brand-new list Blinding Lights 200 s Titanium 245 s One More Time 320 s Strobe 634 s filter < 300 ? The recipe, unrun — nothing collected yet. kept = []
step 0 / 4
Push it to the end: the two songs under 300 s (Blinding Lights, Titanium) land in the new list; the two long ones (One More Time, Strobe) fall through the gate and vanish. The original playlist is never touched — a comprehension always builds, never edits.
Fig 3b — A comprehension is a for-loop wearing one line's clothing: source on the left, a test in the middle, a fresh list on the right. Everything else in the family — {…} for a set, {k: v …} for a dict, (… ) for a lazy generator — is this exact shape with different brackets.
↺ reframe
An idiom isn't shorter code — it's compressed intent. sorted(titles, key=len) says sorted by length in the same breath you read it; the hand-rolled loop makes the reader reverse-engineer that what out of five lines of how. You're not saving keystrokes. You're moving the meaning to the surface so the next person — usually you, in six months — reads intent instead of decoding mechanism.

That's one idiom read to the metal. Here are all fifteen on one card — the loops that count, the phrases that filter and glue, and the small guards that keep a program upright:

IdiomWhat it buys you
enumerate(songs, start=1)a numbered loop, 1-based, with no i = 0 bookkeeping and no off-by-one
[s["title"] for s in songs]build a new list in one pass; swap the brackets for a set or dict
[s for s in songs if cond]a filter goes after the for; a … if … else … ternary goes before it
{s["artist"] for s in songs}a set comprehension — duplicates collapse to uniques for free
dict(zip(titles, seconds))two parallel lists → one dict; zip stops at the shorter, silently
first, *rest = songsunpack a head off a sequence; rest is always a list, even when empty
songs[::-1] · songs[::2]a reversed copy · every second item — a slice is always a new list
songs.sort() vs sorted(songs)mutate in place, returns None · new list, from any iterable
sorted(titles, key=len)a function reference steers the sort — hand over len, never len()
", ".join(titles)glue n strings in O(n); += in a loop is O(n²) — see the deeper cut
with open(path) as f:the file closes itself, even if the block raises halfway through
try: int(input()) except ValueErrorharden every number that arrives from a keyboard or a file
if not songs:the emptiness check — '', [], {}, set(), 0, None are all falsy
while True: … breakloop until done, with the exit test wherever it naturally falls — the middle
x if cond else y · min(v+10, 100)a value chosen, or clamped to a cap, in one expression — no if block
if __name__ == "__main__":run-as-a-script guard; the block runs on python file.py, skips on import
Wait — if a comprehension is just a for-loop, why is it faster? It isn't magic and it isn't a different algorithm — same items, same O(n), same order. The saving is per-item overhead: an explicit loop looks up the .append method and calls it on every pass, while a comprehension compiles to a dedicated LIST_APPEND instruction with no lookup and no call. On this machine, filtering a 1,000-item list clocked ~25.5 µs as a comprehension versus ~28.2 µs as an append-loop — real, but modest. Reach for a comprehension because it reads as one thought, not because it'll rescue a slow program.
The parenthesis trap — the one that bites everyone
key=len hands the sort a function to call later, once per item. key=len() calls it now, with no argument — Python raises TypeError: len() takes exactly one argument (0 given) and never reaches the sort at all. Same rule in map, filter, a Timer, a button's on_click, every callback you'll ever wire up: pass the reference, no parentheses. Parentheses mean run it; the bare name means here's the thing to run.

✗ The myth

songs.sort() sorts the list and hands it back, so ordered = songs.sort() is how you get a sorted copy.

✓ The reality

.sort() rearranges songs in place and returns None — so ordered becomes None and you've quietly thrown your data on the floor. Want a copy? That's sorted(songs). Rule of thumb: the methods that mutate (.sort(), .append(), .reverse()) nearly all return None on purpose, precisely so this line looks wrong.

Three guards that replace an if
The last handful on the card are one-liners that quietly delete a branch. x if cond else y is an expression, so it drops straight inside an f-string or a comprehension where a statement can't go. min(volume + 10, 100) clamps to a ceiling and max(0, volume - 10) to a floor — a two-sided clamp is just both, max(0, min(v, 100)), no if in sight. And if not playlist: leans on Python's rule that empty things are falsy, so it reads as plain English: if there's no playlist. Each one trades a four-line branch for a phrase you take in at a glance.
The deeper cut — why += on strings is a trap, and the caveat that makes it a labelled one

"Glue strings with join, never += in a loop" is the idiom, and the reason is real. A Python string is immutable: it can never be edited in place. So acc += "x" can't extend acc — it has to allocate a whole new string and copy every character across. Do that inside a loop, and as the accumulator grows 1, 2, 3, … up to n characters, the copying totals roughly n²/2 characters. ", ".join(parts) instead measures the total once, allocates once, and copies each character a single time — O(n). Timed on this machine, building a string with += took 8.8 ms at 30,000 items, 33 ms at 60,000, and 127 ms at 120,000 — quadrupling for each doubling, the unmistakable fingerprint of O(n²). join stayed flat at a couple of milliseconds.

Now the honest refinement, so this isn't a half-truth. CPython carries a special-case optimisation that can make s += x update in place when s is a plain string with exactly one reference to it, which sometimes flattens the curve to near-O(n). It's an implementation detail: not part of the language, not present on every interpreter, and it evaporates the moment anything else touches s. The timings above show it failing to save the naive loop even on the interpreter that ships it. So the rule stands as written: reach for join, and never rely on the accident that occasionally rescues +=.

The deeper cut — what __name__ actually holds

Every Python file, when it runs, is handed a hidden variable called __name__, and Python sets it based on how the file was started. Run the file directly — python playlist.py — and __name__ is the string "__main__". Import that same file from somewhere else — import playlist — and __name__ is instead "playlist", the module's own name. Both ways were verified on this machine: a direct run prints __name__ == '__main__' and the guarded block fires; the import prints __name__ == 'playlist' and the block stays silent. So if __name__ == "__main__": is really the question "am I the program the user launched, or a library someone else pulled in?" It's the switch that lets one file be both a runnable script and a reusable toolbox. Chapter 9 leans on it hard.

THE ONE IDEA TO CARRY FORWARD
You don't memorise idioms the way you memorise facts — you install them, the way you installed "turn left" until your hands did it without you. The goal isn't to recall that enumerate exists; it's to reach for it before the manual counter ever occurs to you. When these fifteen phrases become the first thing your fingers try, you've stopped translating your intent into Python and started thinking in it.

Idioms are your hands — the moves your fingers make without asking. Vocabulary is your mouth: twenty-four words defined in one line each, and eight stubborn myths worth leaving at the door before you go →

Wait — if a comprehension is just a for-loop, why is it faster? Not magic, and not a different algorithm — same items, same O(n), same order. The saving is per-item overhead: an explicit loop looks up .append and calls it every pass, while a comprehension compiles to a dedicated LIST_APPEND instruction with no lookup and no call. Real, but modest — reach for it because it reads as one thought, not to rescue a slow program.
ZIP STOPS AT THE SHORTER — SILENTLY
dict(zip(titles, seconds)) is a tidy two-lists-into-one-dict move, but zip quits the instant either side runs out. Three titles against four seconds keeps three pairs and drops the fourth with no warning at alllen(list(zip([1,2,3], [9]))) is 1. Handy, and a quiet way to lose data.
Wait — ", ".join(...) over += isn't a style preference — it is a complexity fix. A string is immutable, so acc += s can't extend acc; it allocates a whole new string and recopies every character so far. Spell an n-character string that way and you ask Python to copy about n²⁄2 characters — half a million for a 1,000-char string. join copies each character once: 1,000.
Trace3 step the machine — idea · code · memory move together
A comprehension is a loop — desugared, then run once
[s.title for s in pl if s.fav] is not magic syntax — it is a for-loop folded onto one line. It desugars to out=[]; for s in pl: if s.fav: out.append(s.title). Watch the one-line form on the left and the unrolled loop on the right move together as a single pass builds the list.
4 songsfilter s.favkeeps 2
Comprehension ≡ loop — one pass, side by sidedesugar · rundesugar
Same three parts, two shapes — keep, loop, filter
the comprehension
[ s.title for s in pl if s.fav ]
what to keepthe loopthe filter
the same loop, unrolled
out = []
for s in pl:
if s.fav:
out.append(s.title)
s
s.fav ?
out[]
In plain words
Under the hood
OPALU · the operation
Program · idioms.py
variables · type
Memory · the list being built
registers — this iteration
out list · grows by append
what's happening
beat 1 / 1

04Twenty-four words — and eight myths

Documentation stops being fog the day a handful of words snap into exact senses. Not synonyms, not vibes, but the single meaning this course leaned on, chapter after chapter, with no drift. Below are twenty-four of them. And here is the secret that makes them learnable: they are not twenty-four unrelated facts. They are twenty-four views of one object. Take the smallest real fact from the playlist we've carried since page one — "Blinding Lights" runs 200 seconds — and store it as secs = 200. That one int is enough to demonstrate almost every word in the list: what it is, where it lives, how Python finds it, and what happens when you try to change it.

So before the list, look at the object itself. Everything the vocabulary names is somewhere in this one picture — the box, the name pointing at it, the little header that makes it cost more than a bare number, and the properties (immutable, hashable) that decide what you're allowed to do with it.

THE ONE IDEA TO CARRY FORWARD
A glossary looks like memorisation and isn't. Learn one object — how Python stores a value and finds it again — and the rest of these words are its shadows: reference is how you reach it, refcount is how it's freed, immutable is what happens when you "edit" it, hashable is whether it can key a dict. Master the box and you've mastered the list.
Six words pinned to one object — the int 200figure
secs a name reference → an address one object on the heap type: int refs: 1 200 0b11001000 · 28 bytes object — the whole box boxed — +header, so 28 B refcount — 0 frees it immutable — edit makes a new id reference — the name secs stores the object's address, never the object itself hashable — hash(200) is 200 and never wavers, so 200 can key a dict or join a set
Fig 04 — Six of the twenty-four words, all pinned to a single int. If you can label this picture cold — box, header, name, count, id, hash — you already own a quarter of the vocabulary.

Now the full list, grouped not alphabetically but by the mental model each word belongs to — the object first, then the containers, then when work happens, then functions and scope, then the machinery under classes. Read each line as the exact sense this course used.

The object model. An object is one chunk of the heap — a value plus the type stamped on it that says what the value can do; our 200 is an int object. It is boxed: the bare number rides beneath a type pointer and a reference count, which is why sys.getsizeof(200) reports 28 bytes, not one. A reference is a name — or a list slot, or a dict value — that stores the object's address, never the object itself. The refcount is CPython's cleanup meter: it tallies how many references aim at the object and frees it the instant that count hits zero. The garbage collector is the backup sweep for the one case refcounting can't catch — objects that reference each other in a cycle, propping both counts above zero forever. The small-int cache is a shortcut: CPython builds every integer from −5 to 256 once at start-up, so every 200 in your whole program is the same object (a = 200; b = 200; a is b is True). An object is immutable when it can never change — int, str, tuple; "editing" one silently builds a new object with a new id, so x = 200; x = x + 1 moves x to a different box. It is mutable when it changes in place and keeps its id — list, dict, set, the whiteboard you wipe and reuse. And it is hashable when it has a stable hash — hash(200) is 200 — which is exactly the ticket to be a dict key or a set member; immutables qualify, a list does not (hash([200, 245]) raises TypeError).

What the containers really are. A dynamic array is a single contiguous block that quietly over-allocates spare slots, then relocates itself to a bigger block when it outgrows them — that's what a list is, which is why indexing is instant yet the block occasionally has to move house. A hash table stores items in buckets addressed by hash(key), so a lookup jumps straight to the bucket instead of scanning — that's what dict and set are. Big-O is the shorthand for how cost grows as the data grows, constants thrown away: O(1) flat no matter the size, O(n) climbing in step with it, O(n²) the nested-loop trap you feel the moment n gets real.

When the work happens. Something is lazy when it computes each value on demand instead of up front — range(10**6) weighs the same 48 bytes as range(10) because it stores a rule, not a million numbers; eager, its opposite, builds everything now and pays now. A generator is the workhorse of laziness: a function frozen mid-run by yield, handing back one value each time you ask, remembering its place, spent once exhausted. A comprehension is a loop folded into one expression that builds a list, dict or set — [s for s in lengths if s > 210] — eager by default, its lazy cousin being the generator expression in round brackets.

Functions and where names live. Functions are first-class: a function is just another object, so you hand its name around with no parentheses — sorted(songs, key=len) passes len itself for Python to call later. Every call gets a stack frame — the scratch space pushed on for that one invocation, holding its local names, popped and forgotten the instant the call returns; frames pile on the stack while every object they point to lives out on the heap. When Python meets a bare name it resolves it by LEGB — Local, then Enclosing, then Global, then Built-in, first match wins. A closure is a function that keeps using names from the scope it was defined in, even after that scope has returned — the enclosing value rides along with it. And pass-by-object-reference is how arguments actually travel: a call binds the parameter to the very same object the caller holds, so mutating it is shared but rebinding the name is local — the fact that dissolves the whole "by value vs by reference" argument.

The machinery under classes. A dunder is a double-underscore method — __init__, __len__, __repr__ — that Python calls for you at fixed moments, so len(x) really runs x.__len__(). Name mangling is the class-body rule that rewrites any __name into _ClassName__name behind your back — a collision guard between parent and child, emphatically not a privacy lock. And the MRO, the method resolution order, is the fixed route Python walks up through parent classes to find a method — bool.__mro__ is (bool, int, object), so a method missing on bool is sought on int, then object, in exactly that order.

When folklore and the interpreter disagree
Every myth below came from real course notes — written by someone who was teaching. That's the point: plausible, confident, and wrong. The cure is always one line at the REPL — run it. The interpreter outranks any note, any blog post, any Stack Overflow answer, and this course too. It is the only referee that never bluffs.
Eight myths, one referee — step through and watch each one die at the REPLinteractive
MYTH 1 / 8 — folklore from chapter 0 ✗ THE FOLKLORE Python is “just interpreted” — there is no compile step. THE REPL — THE ONLY REFEREE »»» type(compile("secs + 45", "src", "eval")) ✓ THE REALITY CPython compiles to bytecode first, then runs it.
1 / 8
Drag through all eight. Every folklore line was believed by someone competent; every REPL line and its output is real, run on this machine. Notice the pattern — you never argue with the myth, you just run the one line that settles it.
Fig 04b — The shape of every disagreement in programming: a confident claim on top, a one-line experiment in the middle, the verdict at the bottom. The REPL is the referee — memorise the habit, not the eight answers.
Wait — if every 200 is the same cached object, is every number? No — the cache runs only from −5 to 256. Build a 257 at runtime twice and you get two different objects: a is b comes back False even though a == b is True. That gap is the whole reason you compare values with == and reserve is for identity questions like x is None.
↺ reframe
A glossary isn't a word list to grind through — it's a compression. Twenty-four terms feels like twenty-four things to remember, but they're twenty-four labels on one picture: an object in a box, a name pointing at it, a rule for freeing it, a rule for changing it. Once the box clicks, you're not recalling definitions — you're just reading the same diagram from twenty-four angles.
The deeper cut — why is sometimes lies about small ints

"A runtime 257 is two different objects" is a labelled simplification, and here's the refinement. The small-int cache (−5 to 256) is one mechanism; the compiler has a second, sneakier one. Two identical literals in the same compiled code object — say 257 is 257 typed on one line — get folded into a single shared constant, so that comparison returns True even though 257 is far outside the cache. Split the two 257s across separate runtime statements and the illusion vanishes: now they're genuinely distinct objects and is returns False. The lesson isn't the boundary number. It's that is on integers is an implementation detail you must never lean on. It answers "same object?", which for values is CPython's private business. Ask "same value?" and the answer is always, portably, ==.

You've named the pieces and buried the myths. But a vocabulary you can recite still isn't a program that runs — so let's build one, end to end: six steps to the playlist app. →

Wait — hash(200) is 200, tidy and stable — but hash(-1) is −2, not −1. Deep in CPython's C code -1 is the value that means "a hash error happened", so the one integer that would naturally hash to −1 gets quietly bumped to −2. A famous edge case hiding inside the plainest function in the language.
__PRIVATE IS A GUARD, NOT A LOCK
Python has no privacy keyword. Inside a class body, any __name is rewritten to _ClassName__name behind your back — name mangling, a collision guard between a parent class and its child. So a.__balance raises AttributeError, yet a._Account__balance hands the value straight over. It hides the name; it never locks the door.
Wait — is and == answer different questions, and small ints make the gap visible. int("257") is int("257") is False — two fresh boxes — while int("256") is int("256") is True, because −5…256 are cached and shared. Compare values with ==; save is for identity questions like x is None.

05Build it: six steps to the playlist app

Reading built recognition: you nodded along, and the words made sense. Building is the other thing entirely. It builds recall, the ability to summon an idea onto a blank screen with nothing prompting you. This section is fourteen chapters folded into one program. Six exercises, each standing on the one before it, each naming the chapters it draws from. You start with three flat lists. By step six, the playlist you have carried since chapter 2 is a real, saving, sorting, class-based app — the same two songs the whole way, just wearing a heavier and heavier coat.

THE RULE FOR ALL SIX
Type every solution from a blank file — no copying, no scrolling back to an earlier chapter. Stuck for fifteen minutes? Re-read only the section the exercise names, close it, and finish from memory. Recall is forged at exactly the moment it hurts to remember; importing the answer forges nothing.

Here is the trick that makes the whole ladder cohere: one small fact — "Blinding Lights" runs 200 seconds — travels through every step, changing its shape but never its value. First it is an item in a list; then a slot in a tuple; then a value under a dictionary key; then an argument to a function; then a field in a line of a file; then an attribute on an object. Watch that single 200 follow you down the page.

One datum, six shapes — the 200 that never changesfigure
the same length of "Blinding Lights" — 200 s — wearing six different costumes 1 · lists titles = [ ... ] seconds = [200, 245] three parallel lists, one index 2 · tuples ("Blinding Lights", "The Weeknd", 200) one row, locked together 3 · dicts {"title": ..., "seconds": 200} named fields, no index to lose 4 · functions total_seconds(pl) → 445 (200+245) behaviour wrapped in a name 5 · a file line Blinding Lights| The Weeknd|200 survives the power going off 6 · a class Song("Blinding Lights", …, 200) data and behaviour, one thing
Fig 05 — Every step re-clothes the identical data. Learn to see the 200 travelling and the six chapters stop being six topics — they are one dataset growing a richer skin.

Step 1 · Parallel lists — chapters 1, 2, 5. Three lists that line up by index: titles, artists, seconds, five songs long, anchored on "Blinding Lights" (200) and "Titanium" (245). Print a 1-based menu with enumerate(titles, start=1), then the total run time as m:ss using sum(seconds) with // 60 and % 60. Check: a single 200-second song formats as 3:20; the five-song total 1160 s prints 19:20.

Step 2 · Tuples and sorting — chapters 6, 10. Zip the three lists into rows: tracks = list(zip(titles, artists, seconds)). Loop with unpacking — for title, artist, secs in tracks: — then make a longest-first copy with sorted(tracks, key=lambda t: t[2], reverse=True) and pluck the single longest with max(tracks, key=lambda t: t[2]). Check: sorted hands back a brand-new list, so tracks[0] is still "Blinding Lights" — the original order is untouched.

Step 3 · Dicts and sets — chapter 7. Rebuild every track as a self-labelling record with a comprehension: [{"title": t, "artist": a, "seconds": s} for t, a, s in zip(...)]. Collect the unique artists into a set, then count songs per artist in a dict using the classic counts[artist] = counts.get(artist, 0) + 1. Check: add a second Avicii track and the set stays the same size while Avicii's count ticks from 1 to 2 — the two structures answer two different questions.

Step 4 · Functions and hardening — chapters 9, 13. Fold the moves into named tools: add_song(playlist, title, artist, seconds), total_seconds(playlist), songs_by(playlist, artist). Read new songs from input() and guard the seconds cast — try: secs = int(raw) / except ValueError: re-prompt. Check: typing abc where a number belongs must re-ask, never crash — an unguarded int("abc") raises ValueError and takes the whole app down with it.

Step 5 · A file that remembers — chapters 11, 13. save(playlist, path) writes one title|artist|seconds line per song inside with open(path, "w"); load(path) reads each line, split("|"), and casts the seconds back to int; wrap the read in try/except FileNotFoundError and return [] on a first run. Check: save, quit, rerun, load — and be sure the seconds compare equal as ints, not strings, or every later sort silently lies.

Step 6 · The class — chapters 11, 12. A Song class (or a @dataclass) with three attributes and a __repr__; a Playlist wrapping a list, exposing add(), total_seconds(), __len__ and __repr__ so len() and print() just work. Stretch: put __slots__ on Song and measure the per-instance size honestly (see the deeper cut), and make total_seconds a generator expression: sum(s.seconds for s in self.songs).

Drag the step control below and climb the staircase one rung at a time. Each step you reach lights gold; every step beneath it — already built, already reused — glows green. Read off the chapters it leans on and the exact check that tells you it works.

Climb the six steps — each one stands on the lastinteractive
1 2 3 4 5 6 Parallel lists draws on chapters 1 · 2 · 5 check — a 200 s song prints 3:20
step 1 / 6
Slide from 1 to 6. Nothing below the current step is ever thrown away — a class in step six still totals seconds with the very // and % you wrote in step one. At the top, the flag turns green: the app is real.
Fig 05b — Six rungs, one app. The gold step is where you stand; the green steps are the finished floor you are standing on.
Wait — if I finished step five's save/load, why retype it in step six instead of just import-ing the file I already wrote? Because the file is not the prize — the retrieval is. Re-deriving total_seconds from a blank buffer is the rep that carves the memory; importing it hands your fingers the answer and carves nothing. The finished app is a by-product; the wired-in recall is the thing you actually keep.

Step six has an exact finish line. Type this out and make it print, character for character — if it does, you have a class, a container, and two dunder methods all cooperating:

playlist.pypython
night = Playlist("Friday Night")
night.add(Song("Blinding Lights", "The Weeknd", 200))
night.add(Song("Titanium", "David Guetta", 245))

print(len(night))             # 2    — your __len__ at work
print(night.total_seconds())  # 445  — 200 + 245
print(night)                 # Playlist('Friday Night', 2 tracks, 7:25)
↺ reframe
You did not practise six separate topics and staple them together. You extended one living object six times — the same playlist, promoted from a loose bundle of lists into a self-describing class. Lists, tuples, dicts, functions, files and classes were never six subjects to memorise; they are six costumes the one dataset can wear, and picking the right costume for the job is most of what "knowing Python" actually means.

✗ The myth

"I read all fourteen chapters and understood every page, so I know Python now." Understanding-while-reading feels like mastery — the words all landed.

✓ The reality

Reading proves only recognition. The proof of recall is a blank file: can you rebuild this playlist with nothing open to copy from? If yes, it is yours. If not, that gap is exactly the section to revisit — and the pain of finding it is the learning.
THE SILENT STRING TRAP
On load, split("|") hands you back strings — every field, seconds included. Forget the int(...) cast and nothing crashes; it just quietly rots. "200" == 200 is False, "90" > "200" sorts wrong because strings compare character by character, and your longest-first list scrambles with no error to warn you. Casting on the way in is not a nicety — it is the difference between a playlist that sorts and one that only looks like it does.
The deeper cut — why the __slots__ stretch fools a naive size check

The stretch goal says "put __slots__ on Song and compare per-instance size." Do it the obvious way and Python appears to punish you for optimising — because sys.getsizeof weighs only the object header in front of you, never the __dict__ it silently drags behind. This is a labelled simplification made honest: the real number is the sum of both.

slots.pypython
import sys
sys.getsizeof(plain)             # 48   — looks tiny...
sys.getsizeof(plain.__dict__)    # 272  — ...but it hauls THIS around too
hasattr(plain, "__dict__")     # True

sys.getsizeof(slotted)           # 56   — a heavier header, but...
hasattr(slotted, "__dict__")   # False — no per-instance dict at all

All-in, the plain Song costs 48 + 272 = 320 bytes (measured on this CPython 3.12); the slotted one costs 56 bytes and carries no dictionary. That is the real ~5× saving __slots__ buys you — invisible until you count the __dict__ that getsizeof refuses to follow. Multiply by a million songs and it stops being a curiosity.

WHEN IT ALL RUNS
You now have a program that reads, validates, saves, reloads, sorts and describes itself — the entire spine of a real application, built from the atoms of fourteen chapters. Keep this file. It is the most honest résumé line you can write: not "completed a Python course," but "here is a thing I built, and I can rebuild it from empty."

The app plays its last track and prints its tidy 7:25. So where do you point yourself next — what is worth learning after the language itself? →

THE CHECKS ARE EXACT — TRUST THEM
Every "check" in the six steps is a real number to test your code against. A 200-second song is 200//60 : 200%603:20; the pair 200 + 245 = 445 prints 7:25; the five-song 1160 s prints 19:20. If your app shows anything else, the // or % is wrong — not your memory.
Wait — add __slots__ to save memory and getsizeof seems to punish you: the slotted Song's own header is 56 bytes, bigger than the plain one's 48. The saving is invisible here — the plain object also drags a separate ~296-byte __dict__ that getsizeof refuses to follow. Count both and the slotted object is roughly 6× leaner. Multiply by a million songs and it stops being a curiosity.
Wait — "200" == 200 is False, and that one gap can rot a whole app. Load a saved playlist, forget the int(...) cast, and your seconds stay strings — nothing crashes, but "90" > "200" is True (strings compare character by character, and '9' beats '2'), so your longest-first sort silently scrambles. Cast on the way in, or it only looks sorted.
Trace3 step the machine — idea · code · memory move together
Growing the Playlist class — one member at a time
A class is not built in one breath — each method you add unlocks a piece of Python that then just works: __len__ powers len(pl), __iter__ powers for s in pl, __repr__ powers print(pl). Press Next and watch the body grow, member by member, until the finish‑line script prints 7:25. (In a real file you type all six methods, then run once — we grow the source only to show what each unlocks.)
6 members2 songs · 445sprints 7:25
class Playlist — grown one member at a timemember 1 / 6
class Playlist:
now this works
In plain words
Under the hood
INITwhat this member does
Program · playlist.py
night · instance
Memory · the night instance
registers — what the built‑ins read
night.songs list · the tracks inside
what's happening
beat 1 / 1

06The road on

Here is an honest map, folded open one last time. Fourteen chapters back you met a bit and could not yet see how it held the number 200. Now you can follow that 200 — the length of "Blinding Lights", byte 0b11001000 — from the switch it rides on, through the box in RAM, up to the Python object and the name that points at it. This course installed the nouns of the language — int, str, list, dict, the frame, the heap, the reference. And, rarer than any tutorial bothers with, it showed you where each one lives in memory. What a book cannot hand you is mileage: the hundred small programs where the model stops being a diagram you recall and becomes a reflex you feel. You don't learn to drive by memorising the car. You learn by driving.

Part 2 exists for exactly that. Not more theory, but real programs, traced line by line, with the stack and the heap redrawn at every single step. A number-guessing game. A round of hangman. An email-slicer that pulls the name out of an address. You won't have to imagine the frame being pushed when a function is called, or the object appearing on the heap when a list is built. You'll watch it happen, one line at a time, and catch the exact instant a bug is born.

THE ONE IDEA TO CARRY FORWARD
From here on, nothing new is smaller than what you already hold. Every chapter ahead is a fresh arrangement of the same atoms — bytes in boxes, objects on the heap, frames on the stack. You are not restarting at the bottom of a harder book. You are turning the model you built into software that ships.
the course was the on-ramp — this is the road it feedsfigure
bits → bytes → objects → the stack & heap — and then out onto the road Part 1 you are here the model, metal → objects Part 2 traces, line by line Volume 2 shipped software Vol 3 · Vol 4 · the Lab
Fig 06a — The course was the on-ramp. Part 2 traces the objects you built through real programs; Volume 2 turns them into software that ships; Volumes 3 and 4 and the Lab are the country beyond. One road, three speeds.

And Volume 2 — the working programmer — picks up on this exact spot and turns the model into shipped code, through six rooms. Step into each one below. Not one of them adds a new atom. A file is bytes leaving RAM for the disk. An import is one namespace borrowing another. A thread is frames taking turns on the same CPU. Every "advanced" topic is a rearrangement of things you already watched happen, which is why none of it needs more than what you now know.

the six rooms of Volume 2 — step through theminteractive
each door is a rearrangement of atoms you already own — step through 1 files & persistence 2 modules & import 3 type hints 4 testing & debug 5 threads & async 6 capstone Data that outlives the process — a file gives your playlist a life after the power cut. playlist in a var → next run: NameError · in a file → still there
1 / 6 — files & persistence
Slide through the six rooms. Each caption is a real payoff, and the line under it is a fact you can run today — the same objects, bytes and frames from this course, simply put to work.
Fig 06b — Volume 2 has six doors, not one locked gate. The highlighted room is never a new beginning — it's the model you already built, aimed at a job.
↻ reframe
You didn't reach the end of Python. You reached the end of the map — and a map is worthless while you're standing still. Everything ahead is the same country you already surveyed: the byte, the object, the frame. Volume 2 just hands you the keys and points at the road.
Wait — if Python throws the annotation away the instant it runs — play("banana") didn't even flinch, and the hint seconds: int just sat there in play.__annotations__ as inert data — then what on earth is a type hint for? Because the check happens before your program runs, not during it. A tool called mypy reads those hints like a proofreader and refuses the manuscript when a str is about to land where an int was promised. The hint is a message to a machine that runs earlier than you do.
DOORS, NOT PREREQUISITES
The six rooms aren't a locked sequence to grind through in order. Reach for persistence the day your program needs to remember something; reach for testing the first time a bug you fixed sneaks back in. Each door opens when a real project knocks — not a moment before.

✗ The myth

Volume 2 is "harder Python" — new syntax, deeper magic, a fresh pile of things to memorise before you can build anything real.

✓ The reality

It introduces no new atom. Files are bytes leaving RAM for the disk; imports are one namespace borrowing from another; threads are frames taking turns on one CPU. Every "advanced" topic is a rearrangement of things you already watched happen at the byte level.
The deeper cut — the honest truth about the GIL, in one measurement

Here's a promise Volume 2 keeps and most tutorials dodge. Standard Python runs with a GIL, a global lock that lets only one thread execute Python bytecode at a time. Line up two threads on pure number-crunching and the clock tells the truth: the two together finish in almost exactly the same wall-time as running them one after the other. Measured on this very machine, two CPU-bound threads clocked a 1.02× "speedup", which is to say none at all. Threads do not multiply CPU-bound Python.

So why does the topic matter? Because most programs spend their lives waiting — on a disk, a network, a reply that hasn't come. While one thread waits, the GIL is free and another thread runs. That's the real win: one thread juggling thousands of idle waits, which is exactly what async is built for. And when you genuinely need many CPUs grinding at once, you step past threads to processes, a separate interpreter each, no shared lock. Volume 2 draws all three — threads, async, processes — with the same stack-and-heap pictures you already read fluently.

Five doors lead out of this room. The trace walkthroughs open the hood for real — the guessing game line by line, every frame pushed and every object born on the heap at the exact moment it happens. Volume 2 — the working programmer begins where every run ends: the process dies and takes the playlist with it, and chapter 15 gives your data a life after the power cut. Volume 3 is the deep one — algorithms & complexity, where you learn to make code fast and think like the 1%. Volume 4 builds the thirteen containers your data lives in, from the bytes up. And the Algorithms Lab hands you the controls — race binary search against linear, sort an array by hand, watch a recursion tree explode. The map is finished. Pick a door and drive →

Wait — a type hint is thrown away the instant Python runs. play("banana") on def play(seconds: int) doesn't even flinch — the hint just sits in play.__annotations__ as inert data. So what is it for? A tool like mypy reads those hints before your program runs and refuses the code when a str is about to land where an int was promised. It is a message to a machine that runs earlier than you do.
THE HONEST TRUTH ABOUT THREADS
Two threads on pure number-crunching don't finish twice as fast — they finish in about the same wall-time as running them one after the other. Standard Python's GIL lets only one thread run bytecode at a time, so two CPU-bound threads clock roughly 1.0× "speedup" — none at all. Threads win only when programs wait — on a disk, a network, a reply — which, mercifully, is most of the time.
↺ reframe
You didn't reach the end of Python — you reached the end of the map. Every "advanced" topic ahead is a rearrangement of atoms you already watched happen: a file is bytes leaving RAM for the disk, an import is one namespace borrowing from another, a thread is frames taking turns on one CPU. Nothing ahead is smaller than what you already hold — it is the same country, aimed at a job.
Trace3 step the machine — idea · code · memory move together
One line, five floors down — text to bytecode to frame to heap to metal
Trace a single statement — Song("Titanium").play() — all the way down the Volume‑1 stack. Floor 2 holds the one genuinely new word: the CPU cannot execute your letters, so CPython first compiles the whole line into bytecode — a compact stream of one-byte instructions (LOAD, CALL, LOAD_METHOD…) that the interpreter's fetch‑decode‑execute loop actually runs. Watch the line start as characters on disk, compile to that bytecode, push a frame on the call stack, allocate objects on the heap, and finally flip switches in RAM. Press Next and light up one floor at a time.
5 floors1 statementlen 8 = 0b00001000
The whole stack — one line descendingsource
1
source textSong("Titanium").play() — 23 characters
text
2
bytecodeLOAD · CALL · LOAD_METHOD · CALL
ops
3
call stackframe: self, title live here
frame
4
heapSong object + str "Titanium"
objects
5
metal · RAM / CPU8 switches = one byte
bits
In plain words
Under the hood
READALU · the operation
Program · player.py
names · type
Memory · the running machine
registers — the running state
Song object class instance · lives on the heap
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 14, in working code

The page you keep open: fifteen small, whole programs that weave classes, dicts, comprehensions, exceptions and recursion into things you could actually ship. Press Next and watch each one run to its last line of output.

The playlist, revisited
The Song/Playlist anchor from earlier chapters, grown into a real object with methods you call.
Counting the world
Three ways to tally — the workhorses of every log parser, ballot box, and word cloud.
A tiny bank
Accounts in a dict, behaviour in methods, and an error that refuses to corrupt the ledger.
Group, total, report
The everyday summarizers — bucket things up, add them across, and print a tidy readout.
Machines that remember, calls that call themselves
State that changes with each event, and functions that lean on smaller copies of themselves.
end of chapter 14 · six sections
iolinked.com
Written by Ajai Raj