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.
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.
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.
| Type | One line | Mutable? | Litmus |
|---|---|---|---|
int | a whole number; Python grows the digits as far as you need — no overflow, ever | no | sys.getsizeof(200) → 28 |
float | a 64-bit IEEE decimal — fast, and quietly imprecise past 15-odd digits | no | 200.0 weighs 24 B |
complex | a pair of floats; the imaginary part is written j, never i | no | (1+2j).imag → 2.0 |
bool | True / False — a relabelled 1 and 0; the falsy ones are '', 0, 0.0, None, empty collections | no | bool("False") is True |
str | immutable text; every "edit" mints a brand-new string elsewhere | no | "Titanium" |
bytes | immutable raw octets, each just a number 0–255 — the very switch-patterns ch0 began with; the tag most MP3s open with | no | b"ID3" → the bytes 73, 68, 51 the CPU also reads as the letters I D 3 |
list / dict / set | the mutable containers — whiteboards you rewrite in place | yes | the decision table, next |
tuple / frozenset | their frozen twins — photographs, and therefore hashable | no | usable as dict keys |
None | the absence of a value; what a bare return hands back | — | test 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.
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.
base + i×stride, one probe — not a walk), dicts and sets jump by hash.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:
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'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.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 →
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.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.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.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.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.
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.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.
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.
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.__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.✗ 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.
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.
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 →
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.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.(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.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.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:
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.
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.
playlist is never touched — a comprehension always builds, never edits.{…} for a set, {k: v …} for a dict, (… ) for a lazy generator — is this exact shape with different brackets.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:
| Idiom | What 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 = songs | unpack 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 ValueError | harden every number that arrives from a keyboard or a file |
if not songs: | the emptiness check — '', [], {}, set(), 0, None are all falsy |
while True: … break | loop 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 |
.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.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.
ifx 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.
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 →
.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.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 all — len(list(zip([1,2,3], [9]))) is 1. Handy, and a quiet way to lose data.", ".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.[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.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.
200figureint. 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.
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.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. →
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.__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.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.
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.
200 that never changesfigureStep 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.
// and % you wrote in step one. At the top, the flag turns green: the app is real.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:
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)✗ 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.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.
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 allAll-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.
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? →
200//60 : 200%60 → 3: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.__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."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.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.)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.
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.
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.✗ 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 →
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.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.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.