iolinked / Python, from the metal up
Python · from first principles

Python, from the metal up

Python looks simple because it hides a beautiful machine: every value is an object with an address, a type, and a reference count. See that once and lists, dictionaries, functions and classes all fall into place — and by the end you'll see why it had to be this way.

Here's the question almost no course answers: when you type x = 10, what actually happens inside the memory of your computer? Answer it properly and you never have to memorize Python again — you can derive it.

from the metal up zero ninja 01000001 — bits & bytes (the metal) Memory — numbered boxes (RAM) Every value is an object Data structures — list · dict · set · tuple Functions & classes Programs that do real things
The whole map — every chapter adds exactly one layer. We start at the bits in memory and climb to real programs; nothing above is ever used before it's built below. Keep these six colours in mind — they recur in every diagram.

00From the metal up

Python feels like magic until you know what's under it — then it feels like machinery, which is better. By the end of this chapter you'll be able to read a binary number, picture RAM as a street of numbered boxes, and explain — precisely — what happens between saving a .py file and the moment it runs. Everything in the next fourteen chapters stands on this floor.

A computer is a box of switches

Strip away the screen, the keyboard, the glowing logo, and a computer is a box of switches — billions of them. Each switch is a transistor: a speck of silicon that either lets electric current through or blocks it. There is no third setting. Off or on. We give the two states names — 0 and 1 — and call one switch's worth of information a bit (short for binary digit). A bit is the smallest possible piece of information: one wire, carrying voltage or not.

Everything your machine "knows" — this sentence, a photo, your favourite song — is ultimately a pattern of ons and offs. Which sets up the puzzle that drives this whole chapter. Take one real fact: the song "Levels" by Avicii runs 200 seconds. Now store that in switches. A switch can say yes or no. It cannot say 200.

switch open — current blocked bit = 0 switch closed — current flows bit = 1
Fig — One transistor, two states: current blocked is 0, current flowing is 1. That's a bit — the atom of all data.

One switch stores exactly two possibilities. So how many switches does 200 need — and arranged how? →

Counting with two fingers

You already know the trick; you just know it in base ten. Read 743 right to left: 3 ones, 4 tens, 7 hundreds — each column worth ten times the one to its right, each digit saying how many of that column to take. Binary plays the identical game with two instead of ten: each column is worth double the one to its right — 8, 4, 2, 1. And because a binary digit can only be 0 or 1, it stops saying how many and just says in or out — which is exactly the one thing a transistor can say. So the binary number 1011 means 8 + 0 + 2 + 1 = 11. Same place-value idea, smaller alphabet.

Group 8 bits together and you get a byte — the standard parcel of computer memory. Why 256? Count the patterns. One switch makes 2. Add a switch and every old pattern splits in two — new switch off, new switch on — so the count doubles each time: 2, 4, 8, 16, 32, 64, 128… and at eight switches, 2⁸ = 256 distinct patterns, enough to count from 0 to 255. And look: our song length fits. 200 = 128 + 64 + 8 = 11001000 — one byte holds the running time of "Levels". (The song itself — the audio — is millions of bytes; the thing that fits in one byte is this one small number about it.) Drag the slider below and watch the switches do it. (Later, Python even lets you flip these bits yourself with operators like << — chapter 4.)

2⁰ 8 4 2 1 1 0 1 1 8 0 2 1 + + + 8 + 0 + 2 + 1 = 11
Fig — Binary is place value with doubling columns: 1011 is 8+0+2+1 = 11. No new math, just base 2.
InteractiveOne byte — flip 8 switches, make any number 0–255
128 64 32 16 8 4 2 1 1 1 0 0 1 0 0 0 128 + 64 + 8 = 200 0b11001000
200
Each switch is worth double its right-hand neighbour — 8 of them cover every number from 0 to 255. Find 200 (the length of "Levels" in seconds), then 255, then 1.
📦
You've been reading bytes for years
Ping a server and it answers "64 bytes from 216.239.38.120". An MP3 of "Levels" at high quality is roughly eight million bytes. The byte is the unit everything else is priced in.

A byte maxes out at 255, and a playlist is thousands of bytes of titles and lengths. Where do they all live? →

RAM: a street of numbered boxes

RAM (random-access memory) is where a running program keeps its data, and the honest picture is almost embarrassingly simple: one enormous row of byte-sized boxes, each stamped with a serial number called its address. Box 0, box 1, box 2… on an 8 GB machine, about 8.6 billion boxes. "Random access" means you can open box 5,000,000 as instantly as box 5 — you don't walk the street, you jump straight to the number.

Store our song and you can see the layout. The title "Levels" takes six boxes, one letter per byte — the box for L actually holds the number 76, a shared letter-code (first approximation for now; chapter 2 refines it). The length 200 fits in one more box. Values too big for one byte — anything over 255 — get a fixed team of boxes, typically 4 or 8 working as one. Store a whole row of song lengths in 4-byte teams and their addresses tick past like house numbers on one side of a street: 6000, 6004, 6008… always 4 apart. That even stride makes "give me length #3" instant — the machine computes start + 3 × 4 and jumps straight to the box; arithmetic, not searching. Be precise about what this picture is, though: it's the raw-metal layout, the way C and NumPy pack a row of numbers. Python's own list reuses the even-stride trick but with a twist — its evenly spaced slots hold not the lengths themselves but the 8-byte addresses where each length-object lives — and chapter 6 shows that layout in full. Your program doesn't pick its own boxes, either — the operating system assigns each program its share.

RAM — one long row of byte-sized boxes, each with an address L 5000 e 5001 v 5002 e 5003 l 5004 s 5005 200 5006 one byte per box · 8 GB ≈ 8,600,000,000 boxes · the address is how you find anything
Fig — "Levels" as seven numbered boxes: six letter-codes plus the length 200. Data has no home without an address.
RAM forgets
RAM is volatile: cut the power and every box blanks. That's why unsaved work dies with a crash. Your SSD is the opposite — persistent, but far slower — so "loading" a file just means copying its bytes from SSD boxes into RAM boxes.
Wait —
box 5002 holds 118 (the letter "v") and box 5006 holds 200 (a length in seconds). Both are just numbers. How does the machine know which is which?

It doesn't. A box holds a number, full stop — the meaning lives in the program that reads it. Hold that thought; it becomes Python's type system in chapter 2.

The deeper cut
Real tools write addresses in hexadecimal — base 16, digits 0–9 then A–F. One hex digit is exactly 4 bits, so one byte is always two hex digits: 255 = 0xFF, and our address 5000 = 0x1388. Also: the addresses your program sees are virtual. Each program gets a private, pretend address space, and the MMU (memory-management unit, hardware beside the CPU) translates virtual to physical on every access — which is how two programs can both "own" address 5000 without a fistfight.

Billions of boxes, patiently holding numbers. Boxes don't add, compare, or copy themselves. Who does? →

The CPU: the only part that does anything

The CPU (central processing unit) is the machine's single busy worker. Its whole life is one loop, the fetch–decode–execute cycle: fetch the next instruction from RAM, decode what it means, execute it — then again, forever. Each instruction is tiny: copy a box into the CPU, add two values, compare, write a box back. The CPU keeps a handful of private scratch cells called registers — faster than RAM for the plainest reason: they're etched into the CPU chip itself, so a value doesn't have to travel the wires from the RAM street at all — and does its actual arithmetic there, in a circuit called the ALU (arithmetic-logic unit). A quartz clock paces the loop at around 4 GHz: four billion cycles per second.

Watch it total our playlist: the lengths of "Levels" (200 s) and "Titanium" by David Guetta (245 s) sit in RAM as two 4-byte values; the CPU loads both, adds, and stores 445 back. Step through it below.

FETCH read next instruction from RAM DECODE work out what it means EXECUTE do it — add, copy, compare 4 GHz ≈ 4,000,000,000 cycles / second one cycle ≈ a quarter of a nanosecond
Fig — One dumb loop, repeated four billion times a second, is all the "intelligence" hardware has.
InteractiveStep the CPU — add two song lengths
next instruction: — (drag the slider) CPU A · B · ALU — the adder circuit bus RAM 6000 200 Levels 6004 245 Titanium 6008 · total Two song lengths sit in RAM. The registers are empty scratch cells.
step 0 / 4
Every program you will ever run — including Python itself — reduces to steps this small.
💡
Feel the speed
Those four instructions take the CPU about one nanosecond. In the time it took you to read this sentence, it could have totalled a playlist of a few billion songs.

But notice what the CPU obeyed: terse binary instructions, specific to its chip family. You write English-ish text in a .py file. Someone has to bridge that gap →

The ladder from your text to the metal

A CPU understands exactly one language: its own machine code — raw binary instructions, different for each chip family (your laptop's x86 chip and your phone's ARM chip speak different dialects). What you write is source code: human-readable text. Source code must be translated — and there are two classic strategies. A compiler translates the whole program to machine code once, ahead of time, leaving a fast, ready-to-run executable (C, C++, Rust). An interpreter is a program that reads your code and performs it on the spot, translating as it goes — nothing saved, slower, but instant to start. Picture a novel translated once and printed forever (compiler) versus a live interpreter at a summit, re-translating the same speech every single time it's delivered (interpreter) — that re-translation on every run is exactly where interpreted programs spend their extra time. (A JIT is the hybrid: start interpreting, compile the hot parts mid-run — the JVM, V8.)

✗ The myth

Install Python and your CPU "learns Python". The chip runs your .py file.

✓ The reality

The CPU only ever runs machine code. python.exe is machine code; your .py file is data — text — that this program reads and acts out.

And here's Python's actual pipeline, which is subtler than "interpreted". When you run a file, CPython first compiles your text into bytecode: compact instructions for a made-up, portable CPU — an intermediate representation, halfway down the ladder. Then CPython's virtual machine — a program that behaves like that made-up CPU — interprets the bytecode, one instruction at a time.

SOURCE CODE — human-readable text (.py) song_seconds = 200 CPython compiles it — whole file, automatically BYTECODE — portable intermediate code LOAD_CONST 200 · STORE_NAME song_seconds the VM interprets it — one instruction at a time CPYTHON VIRTUAL MACHINE — a CPU made of software itself already machine code (compiled from C, long ago) which the real CPU executes HARDWARE CPU — machine code only, architecture-specific (x86, ARM)
Fig — Python's real pipeline: compile to bytecode, then a software CPU interprets it. Two rungs, not one.
↺ The thing people get backwards
Most think "compiled vs interpreted" is a property of the language — that Python simply is interpreted. It's really a property of the implementation, and CPython does both: it compiles your file to bytecode, then interprets that bytecode. "Is Python compiled or interpreted?" — yes.
🔍
You can see the bytecode
Python ships a disassembler. Once chapter 1 has you running files, try this — every instruction it prints is one tick of the virtual machine.
python
import dis

def total_seconds(levels, titanium):
    return levels + titanium

dis.dis(total_seconds)
# prints the function as VM instructions:
# two LOAD_FASTs, an add, a RETURN
The deeper cut
CPython caches compiled bytecode on disk — that's the __pycache__ folder, holding files like playlist.cpython-312.pyc — so imported modules skip recompiling next run. The number is the CPython version: bytecode is portable across operating systems but not across Python versions (machine code is portable across neither). And the VM is a stack machine: LOAD instructions push values onto a small stack, operations pop them and push results — a design you're about to see echoed in how calls are managed.

So a software CPU is executing your program. While it runs, it must park thousands of values somewhere in those RAM boxes. It turns out the parking lot has two very different districts →

Two districts of memory: stack and heap

The RAM your program is given gets organised into two working regions with opposite personalities. The stack is the bookkeeping pile. First, the word: a function is a named mini-program inside your program — something like play_song, which you call to start and which returns to whoever called it when it finishes (you'll build your own later in the course). Every function that's currently running gets a frame — a small record of where it was called from and what its local names are. Frames stack up as calls nest and pop off as calls return: last opened, first closed (LIFO). It's rigidly tidy and cleans itself. The heap is the open floor where the actual stuff lives: the number 200, the text "Levels", and later whole playlists — objects of any size, created in any order, living as long as needed. Nothing on the heap cleans itself, so Python runs a garbage collector: machinery that frees any object nothing refers to anymore.

Plant this sentence deep — it's the most useful one in the course: in Python, your values live on the heap, and names just point at them. Chapter 3 will prove it to you with a one-line experiment. (One honesty note: in CPython the frames are themselves small objects kept in heap memory — "stack" names the strict last-in-first-out discipline, not a fenced-off street. The roles are exactly as drawn.)

your program's RAM frame: main program frame: play_song() grows ↓ with each call, shrinks on return 200 "Levels" "Titanium" STACK — call bookkeeping one frame per running function last in, first out; cleans itself HEAP — where objects live any order, any lifetime reclaimed by the garbage collector
Fig — Stack for who is doing what, heap for the things themselves. Every chapter ahead lives in this picture.
Wait —
if the heap holds the number 200 as an object, how big is that object? A byte held 200 just fine — does Python really spend more?

It does — 28 bytes for a small integer, and chapter 2 will show you exactly where they go. That trade (space for flexibility) is the running story of this course.

Enough theory — time to make the ladder fire for real. Next: install Python, type one line, and watch print("Levels") travel from your keyboard to the metal and back →

01Running Python

Time to make chapter 0's machine actually do something. In this chapter you'll install Python, talk to it live, save a real program to disk and run it, and print anything you want — with control over every space. By the end you'll know exactly what CPython does to your file in the instant before line 1 runs, and you'll have written this course's first real program: a playlist that announces itself on a freshly wiped screen.

A conversation with Python

Open a terminal — the window where you type commands at your computer — and type python. Three angle brackets appear: >>>. That's a prompt. Python is waiting. Type a line, press Enter, and it answers immediately.

python
>>> 200 + 64
264
>>> print("Levels", "by", "Avicii")
Levels by Avicii
>>>

This mode is the REPL, and the name is literally what it does: Read your line, Evaluate it (work out its value), Print the answer, Loop back for the next line. Notice you never asked to see 264 — the REPL echoes the value of whatever you type, as a courtesy. Under the hood, each line is translated to bytecode in RAM (more on that word shortly), executed, and forgotten. RAM is volatile — chapter 0 — so closing the window evaporates the whole conversation.

💡
Getting Python (five minutes)
Download it from python.org. On Windows, tick "Add Python to PATH" in the installer — one checkbox, and skipping it is the number-one beginner injury. Then open a fresh terminal and type python --version. If a version number answers, you're armed. On macOS and Linux the command is usually python3.
>>> 200 + 64 264 >>> print("Levels") Levels >>> _ Read you type Eval Python runs it Print answer appears Loop — back for your next line
Fig — The REPL: read your line, evaluate it, print the answer, loop. Close the window and RAM forgets the whole conversation.
Wait —
Python answered 264 without being asked. The REPL echoes everything. But how do you make it say exactly what you want, the way you want it?

That takes Python's voice: one function, and one very underrated comma →

print() — and the comma that types a space

print() is a function: a named action you fire by writing its name with parentheses, putting whatever it should show between them. Hand it several things separated by commas and it prints them left to right with exactly one space between each — a space you never typed. And the things don't have to match: text and a number ride the same line without complaint.

python
>>> print("Levels", 200)
Levels 200
>>> print("Levels" + 200)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
>>> print("Levels" + str(200))
Levels200
>>> print("Levels\nWake Me Up")
Levels
Wake Me Up

Now the trap — walk the four prints above one at a time. The first uses commas: Levels 200, free space included. The second uses +, which also joins things — but it's a different tool: + glues two pieces of text edge to edge, and both sides must be text. 200 is a number, so Python refuses with a traceback — read it bottom line first: the bottom names the complaint (TypeError), the lines above say where. The third print is the cure: str(200) manufactures the three-character text "200" out of the number, and now + glues happily — with no space, because glue adds nothing. So the comma isn't glue at all. It's a delivery list: print slips a separator between deliveries (sep, default one space) and one closing character after the last (end, default \n — the newline character, which the fourth print drops inside the text itself to break the line by hand).

🎛️
Two knobs most people never find
print("Levels", "Avicii", 200, sep=" | ") prints Levels | Avicii | 200. And print("Loading", end="") prints without moving to the next line — the next print continues right there. Every print you'll ever write is just deliveries + sep + end.
delivery 1 (text) delivery 2 (number) "Levels" 200 , print() between items: sep = " " · after the last: end = "\n" Levels200 ␣ = sep (one space, free) ⏎ = end (a newline)
Fig — print() slips sep (default: one space) between deliveries and end (default: \n) after the last one.
InteractiveSwap the joiner — watch the space appear and vanish
terminal print("Levels", 200) Levels200 ␣ = one space — typed by print, not by you print("Levels" + str(200)) Levels200 no space — + glues text edge to edge (and 200 needed str() to count as text) print("Levels", 200, sep="-") Levels-200 sep replaces the default space with anything you choose
comma
The comma is a formatting instruction — "put sep between these" — not glue. + is glue, and glue adds nothing.

One-liners die with the window. To make Python perform the same lines every time, the lines need a home on disk →

Scripts: lines that live on disk

Type your lines into any text editor and save them as my_first_script.py — plain characters on disk, nothing magic; the .py ending just declares "this is Python source." Then run it from the terminal's own prompt (the one like C:\Users\you>), not from inside the REPL — the REPL speaks Python, not terminal commands, so if you see >>>, type exit() to leave first. Then: python my_first_script.py.

python
# my_first_script.py
# a comment: from '#' to the end of the line, Python skips everything

print("MY PLAYLIST")   # comments can share a line with code
200 + 64               # legal, computed… and silently thrown away

Two things change the moment code moves into a file. First, the echo is gone: a script computes 200 + 64 and discards it — anything you want to see must go through print(). Second, you gain comments: notes to the only person who will ever be confused by this file — future you.

Execution is strictly top to bottom, one line at a time. If line 4 attempts something impossible, lines 1–3 have already printed — their output stays — and everything after line 4 never happens. Drag through it:

InteractiveRun a script one line at a time — into a crash
playlist.py 1 print("MY PLAYLIST") 2 print("Levels", 200) 3 print("Wake Me Up", 247) 4 print("Levels" + 200) 5 print("done") ✗ never reached terminal MY PLAYLIST Levels 200 Wake Me Up 247 Traceback (most recent call last): File "playlist.py", line 4 TypeError: can only concatenate str (not "int") to str (line 5 never ran — the program is dead)
nothing has run yet
Runtime rule: everything printed before the crash stays on screen; everything after the crash never happens.
REPL — a conversation >>> "Levels" 'Levels' ← echoed, free of charge >>> 200 + 64 264 vanishes when the window closes playlist.py — a script 1 "Levels" ← silent 2 200 + 64 ← silent 3 print("Levels") run it → the only output: Levels saved on disk — run it any time, forever
Fig — The REPL echoes every value back; a script stays silent unless you print().
Wait —
a script computes 200 + 64 and throws the result away. Where did that 264 live during its half-second of existence? (In RAM — and chapter 3 will show you its actual address.)

"Top to bottom, line by line" is a half-truth, though. In the instant before line 1 runs, Python has already read your entire file. What was it doing? →

The hidden compile step

Here's what actually happens when you run python playlist.py. Step one: CPython reads the entire file and compiles it — translates your characters into bytecode, a compact list of instructions (each operation code fits in a single byte, hence the name). Step two: the Python Virtual Machine (PVM) — a CPU made of software, living inside the python program — executes those instructions one by one. Your real CPU never sees your source; it runs the PVM, and the PVM runs your bytecode. That's the middle rung of chapter 0's translation ladder, live.

↺ The thing people get backwards
Most think "Python is interpreted, so nothing gets compiled." It's really compile first, interpret second: CPython translates your whole file into bytecode before line 1 runs, then its virtual machine interprets that bytecode. The proof: leave a quote unclosed on line 50, and line 1 never prints.
playlist.py source text (characters) ① compile bytecode compact PVM instructions ② execute Python Virtual Machine a CPU made of software Levels 200 __pycache__/*.pyc bytecode cached on disk (imports)
Fig — CPython compiles the whole file to bytecode first; the Python Virtual Machine then executes it. Bytecode lives in RAM — only imported files get the .pyc disk cache.

The bytecode is built in RAM and discarded when the program ends — with one exception: files you import get their bytecode cached on disk as .pyc files inside a __pycache__ folder, so the next run skips recompiling them. Your main script is small enough that CPython just recompiles it every time.

✗ The myth

Python reads your source one line at a time, translating as it goes — so errors only ever show up when their line runs.

✓ The reality

The whole file is compiled up front. Grammar mistakes (SyntaxError) kill the run before any output. Only legal-but-impossible operations — like "Levels" + 200 — wait until their own line executes.
file A: unclosed quote, line 4 compile ✗ no output at all — not even line 1 file B: "Levels" + 200, line 4 compile ✓ runs lines 1–3 line 4 ✗ output of lines 1–3 stays on screen
Fig — Two errors, two moments: grammar fails before line 1; impossible operations fail on their own line, mid-run.
The deeper cut

You can watch the compile step happen. The dis module ("disassemble") prints the bytecode of any snippet:

python
>>> import dis
>>> dis.dis('print("Levels", 200)')
  LOAD_NAME     print      ← find the thing named "print"
  LOAD_CONST    'Levels'   ← stage the text
  LOAD_CONST    200        ← stage the number
  CALL          2          ← call it with 2 arguments
  RETURN_VALUE

(Trimmed and lightly reformatted — exact opcodes vary by Python version.) Two details worth savoring. Each instruction is tiny — an opcode plus a small argument — which is why megabytes of source shrink to compact bytecode. And LOAD_NAME print means even print is just a name that gets looked up at runtime — a name that could, in principle, point at something else. Names pointing at things is chapter 3's whole story.

The PVM happily runs your bytecode — but it can't wipe your terminal or open Notepad. That power belongs to someone else →

Borrowing the operating system

Your program owns almost nothing. The screen, the files, the other apps — those belong to the operating system, chapter 0's manager. So when your terminal fills with clutter, Python can't wipe it directly; it has to ask. That's os.system(), and programmers call the move shelling out. The shell is the program living inside your terminal window — the thing that reads each command you type and gets the OS to run it. Shelling out means handing that same program a command from inside Python, exactly as if you had typed it into the terminal yourself.

python
>>> import os
>>> os.system('cls')       # Windows — the screen wipes clean
0
>>> os.system('notepad')   # opens Notepad; Python waits until you close it
0

Two new words in line one. os is a module — a toolbox of ready-made code you bring in with import. system is one function inside it; the dot means "look inside." When it runs, Python pauses, the OS starts a subprocess — a separate little program — runs your command string, and hands back an exit status: a number, where 0 means "went fine." That's why the REPL shows a 0 afterwards — it's echoing the returned status, like it echoes everything.

⚠️
'cls' is Windows dialect
On macOS and Linux the command is clear. The cross-platform one-liner — steal it now, understand its if/else machinery in chapter 5 — is os.system('cls' if os.name == 'nt' else 'clear').
🧨
It runs anything
os.system() executes whatever string you hand it, with all of your user account's powers — deleting files included. Never feed it text you didn't write yourself, and especially never text that came from someone else's keyboard.
your program os.system('cls') …pauses and waits operating system shell runs cls in a subprocess screen wiped "run this command" returns exit status 0 = "all good" one round trip: your program borrows the OS for a moment
Fig — os.system() hands a command to the operating system's shell and gets back an exit status.
Wait —
if Python can ask the OS to run anything… is Python itself just another program the OS is running? (Yes. The PVM is a subprocess of your terminal — a process like any other.)

Comments, import, a screen wipe, and comma-printing — every part is on the bench. Time to assemble the first machine →

The first real program

Everything this chapter taught, in eleven lines. Save it as my_playlist.py, then run python my_playlist.py:

python
# my_playlist.py — the first real program
import os

# wipe the screen ('cls' is Windows; use 'clear' on macOS/Linux)
os.system('cls')

print("MY PLAYLIST")
print("===========")
print("Levels", "by Avicii —", 200, "seconds")
print("Wake Me Up by Avicii —", 247, "seconds")
print("\nThat's the whole list. For now.")

The screen wipes, and the playlist announces itself. Look closely at the two song lines: one passes 200 as its own comma-delivery, the other tucks the artist inside a longer text — both come out with clean single spaces, because the commas handle spacing for you. And the \n in the last line buys a blank line before the sign-off, no extra print needed.

C:\Users\you> python my_playlist.py MY PLAYLIST =========== Levels by Avicii — 200 seconds Wake Me Up by Avicii — 247 seconds That's the whole list. For now.
Fig — python my_playlist.py: your first program's actual output — comma spacing and \n doing quiet work.
📝
Make it wait for you
Add input("Press Enter to stop the music…") as the last line. input() blocks — the program stands perfectly still until you press Enter. It isn't frozen; it's listening. And whatever you type comes back as text, always — even if you type 200. That little word "text" is about to matter a lot.

One nagging detail hides in plain sight: print happily seats "Levels" and 200 side by side, yet + refuses to touch them. Python is sorting your values into kinds — kinds with different rules and different sizes in RAM (a plain number like 200 costs 28 bytes; the text "Levels" costs even more). What exactly are these kinds, and what's inside their boxes? Chapter 2 opens them →

02Values & types

Chapter 1 got Python running your lines. Now meet the things those lines create: values. By the end of this chapter you'll know every core value type — whole numbers, decimals, text, raw bytes, True/False, and a deliberate "nothing" — and you'll be able to ask any value what it is, list everything it can do, and weigh it in bytes.

One line, three moves — everything is an object

Type one line about our song "Levels" by Avicii:

python
seconds = 200          # the length of "Levels"

title = "Levels"
print(type(seconds))   # <class 'int'>
print(type(title))     # <class 'str'>

It looks like one action. Python performs three. First, object creation: somewhere in RAM (chapter 0's numbered boxes) Python builds an object — a package holding the value 200, a link to its type (int), and through that type, dozens of ready-made behaviours. Second, name creation: seconds becomes a name that stores the object's address. Third, assignment: = binds the name to the object. The object comes first; the name is stuck on afterwards.

That's the founding axiom of Python: everything is an object. Not "most things" — everything. 200, "Levels", True, even nothing-at-all. Every value is data + type + behaviour, travelling together. (Two sibling axioms while we're here: Python is case-sensitiveTitle and title are different names — and indentation carries meaning, which chapter 5 makes precise.)

1 — object built first int object 200 type: int · methods: bit_length(), … lives at address 0x55f3…9d0 2 — name created seconds 3 — = binds
Fig — seconds = 200: the object exists before the name; = only attaches a label to an address.
↺ The thing people get backwards
Most think the type belongs to the variable — declared once, fixed forever, like C's int x. It's really the type belongs to the value. Write track = "Levels" and later track = 200: perfectly legal, because track never had a type. Each object knows its own type; the name is just pointing at whichever object you gave it last.

Here's the map of what we're about to meet — and what waits in later chapters.

covered in this chapter int float complex bool subtype of int str bytes NoneType coming — chapters 5 to 7 list tuple range dict set frozenset bytearray memoryview
Fig — the full built-in type map: this chapter covers the single-value types; the containers arrive in chapters 5–7.

If every value is an object that knows its own type — what types are there? Start with the numbers →

Three kinds of number

Python ships three numeric types. int holds whole numbers with arbitrary precision: there is no maximum. Most languages park integers in a fixed 32- or 64-bit box, and a number that outgrows its box overflows — wraps back around like a car odometer rolling past 999,999. A Python int refuses to wrap: it simply grows, chunk by chunk, as big as your RAM allows. float holds decimals in exactly 64 bits — the IEEE-754 layout wired straight into your CPU's circuits, which is why floats are fast, fixed-size, and (as you'll see) slightly approximate. complex holds numbers like 3 + 4j — Python uses j for the imaginary unit, not the mathematician's i. And int("200") converts — casts — the text "200" into the int 200: the cure for chapter 1's warning that input() always hands you text.

python
seconds = 200                    # int   — whole
minutes = 3.333                  # float — "Levels" in minutes: 200/60, near enough
z = 3 + 4j                       # complex — j, not i

print((10).bit_length())         # 4  — because 10 is 0b1010: four bits
big = 1267650600228229401496703205376   # that's 2 to the 100th power
print(big.bit_length())          # 101 — still exact, no overflow

One correction worth burning in, because a widely-copied note gets it wrong: (10).bit_length() is 4, not 32. Ten in binary is 1010 — four bits — and Python stores just those significant bits. There is no "32-bit representation" hiding underneath. Prove it yourself:

InteractiveSlide the number — watch its bits and bit_length()
10 0b1010 bit_length() = 4 0 0 0 0 1 0 1 0 128 64 32 16 8 4 2 1 significant bits — what bit_length() counts
10 → 4 bits
10 is 0b1010, so (10).bit_length() is 4 — there is no fixed 32-bit box; Python keeps only the bits the number needs.
⚠️
Floats are approximations
Try 0.1 + 0.2 — you get 0.30000000000000004. Not a bug: 0.1 has no exact binary form (like ⅓ has no exact decimal form), so the 64-bit float stores the nearest representable value. Fine for song lengths and averages; never for money.
float — always the same 64 bits (IEEE-754) 1 sign 11 exponent 52 fraction bits 3.14 is stored as the nearest binary fraction those 52 bits can express int — grows digit by digit, no ceiling 30-bit digit 30-bit digit 30-bit digit + more as needed… 2¹⁰⁰ needs 101 bits → 4 digit-chunks → the object grows; the value stays exact
Fig — a float is a fixed 64-bit box (fast, approximate); an int stretches to fit any whole number (exact, growable).
The deeper cut
CPython stores an int as a sign plus an array of 30-bit "digits" packed into 4-byte slots — sys.getsizeof(200) is 28 bytes (24-byte object header + one digit), and each extra digit adds 4 more, so 2**100 weighs 40. A float is the header plus one raw IEEE-754 double: 1 sign bit, 11 exponent bits, 52 fraction bits, good for 15–17 significant decimal digits. That's why float arithmetic runs at CPU speed and never grows — and why 0.1 is really 0.1000000000000000055511…, the closest 52-bit fraction to one tenth.

Ints, floats, complex — done. But Python hides a fourth number in plain sight, wearing a costume: True

True, False, and a deliberate nothing

bool has exactly two values, True and False (capitalised — axiom two, case matters). Here's the surprise: bool is a subtype of int. True literally equals 1 and False equals 0 — same object layout, same 28 bytes, just printed differently.

python
print(True == 1)              # True
print(isinstance(True, int))  # True — bool IS a kind of int
print(True + True)            # 2   (yes, really)

print(bool(""))               # False — empty string
print(bool("False"))          # True  — non-empty string, content irrelevant

next_track = None             # nothing queued after "Levels"
print(type(next_track))       # <class 'NoneType'>

Any value can be squeezed into a bool with bool(x) — that's truthiness, and chapter 5 builds decisions on it. The rule for strings trips everyone: only the empty string is False.

✗ The myth

bool("0") and bool("False") are surely False — the text says so.

✓ The reality

Both are True. Truthiness checks whether the string is empty, not what it spells. Only bool("") is False.

And then there's None — Python's deliberate "nothing". Not zero, not empty text: a real object whose entire meaning is "no value here, on purpose". Our playlist uses it honestly: next_track = None says nothing is queued, which is different from a track titled "". And because None carries no data, no two Nones could ever differ — so Python builds exactly one None object at startup and hands that same object to everyone who needs it. At 16 bytes — a bare object header with nothing inside — it's the cheapest value Python has.

int bool True (=1) False (=0) isinstance(True, int) → True NoneType None exactly one, shared program-wide
Fig — bool lives inside int (a subtype); None is its own type with a single shared instance.
Wait —
if True is 1 and False is 0, does sum([True, False, True]) equal 2? It does — counting how many checks passed is a one-liner, and now you know why.

Numbers sorted. But a playlist is mostly text — and Python's text type has a strict rule: look, but never touch →

Text is frozen; bytes are raw

str is Python's text type: a sequence of Unicode code points. Chapter 0 stored the L of "Levels" as the number 76 and promised a refinement — here it is. Unicode is the world's shared character table: every character ever written gets one agreed number, its code pointL is 76, é is 233, 🎵 is 127925. A string is a row of those numbers, each wearing its standard meaning. And str is immutable: once built, it can never be edited. Every string method that "changes" text actually returns a brand-new string and leaves the original untouched.

python
track = "levels by avicii"
print(track.title())        # Levels By Avicii
print(track.find("avicii")) # 10  — index where it starts
print(track.find("daft"))   # -1  — "not found" is -1, not an error
print(track.replace("levels", "wake me up"))  # wake me up by avicii
print(track)                # levels by avicii — the original never changed

# title = "Levels"
# title[0] = "l"   # TypeError: 'str' object does not support item assignment

bytes is the other sequence here: raw numbers 0–255, no meaning attached. Text must be encoded into bytes to leave your program — "Levels".encode() gives b'Levels' — and indexing bytes hands back plain numbers, not characters.

str — text as Unicode code points L e v e l s "Levels" 76 101 118 101 108 115 bytes — the same six values, raw 0–255 76 101 118 101 108 115 b"Levels" what files, networks, and every MP3 of "Levels" actually carry
Fig — str is numbers-with-meaning (code points); bytes is the same wire-level numbers with the meaning stripped.
💡
Why bytes exist at all
Everything outside your program — files on disk, packets on the network, the MP3 of "Levels" itself — is bytes. str only exists inside Python, after decoding. When files and networks arrive in later chapters, this type is the doorway.
"Levels" id: …a3f10 .upper() "LEVELS" id: …b7c48 — a NEW object title[0] = "l" ✗ TypeError — no in-place edits, ever
Fig — string methods never edit; they build a fresh object at a fresh address and hand it back.
🎯
Gluing text to numbers: f-strings
"Length: " + seconds raises TypeError: can only concatenate str (not "int") to str — Python refuses to guess. The fix is an f-string: f"Length: {seconds}s"Length: 200s. Anything in the braces is converted to text for you.
The deeper cut
CPython stores strings with a flexible representation: 1, 2, or 4 bytes per character, chosen by the widest character present. All-ASCII "Levels" costs 1 byte per letter (41-byte base + 6 = 47 via sys.getsizeof). Append a single 🎵 and the entire string is re-stored at 4 bytes per character — one emoji quadruples the per-character price of every plain letter around it. Immutability also has an algorithmic price: building text with += in a loop copies the whole string every pass — O(n²). The cure ("".join(...)) arrives with lists in chapter 6.

Seven types now, each with its own tricks. How do you interrogate a value you've never met — and find out what it costs? →

Interrogate any value — and weigh it

Because every value is an object carrying its type and behaviour, Python gives you an interrogation kit that works on anything. type(x) asks "what are you?". isinstance(x, int) asks "are you this kind?" — smarter than comparing types directly, because it respects subtypes (isinstance(True, int) is True). dir(x) lists everything the object can do — try dir("Levels") and dozens of string methods spill out. id(x) returns a unique integer: in CPython, the object's address in memory — chapter 3 turns that into a superpower. And sys.getsizeof(x) weighs the object in bytes.

python
import sys
title = "Levels"

print(len(title))              # 6      — a FUNCTION: object handed in
print(title.upper())           # LEVELS — a METHOD: reached with a dot
print(isinstance(title, str))  # True
print(sys.getsizeof(200))      # 28 bytes
print(sys.getsizeof(title))    # 47 bytes
print(id(title))               # e.g. 140399482229104 — where it lives

Notice the two shapes in that snippet. len(title) is a function: free-standing, the object goes in as an argument. title.upper() is a method: behaviour defined inside str itself, reached through the object with a dot. Same idea — code acting on a value — different address for the tool.

function — the object goes IN len( "Levels" ) → 6 free-standing tool; works on many types method — the tool lives INSIDE "Levels" .upper() .title() .find() … packed inside every str, reached with a dot title.upper() → "LEVELS"
Fig — len(title) hands the object to an outside tool; title.upper() uses a tool the object carries with it.

Now weigh the whole cast. Every literal from this chapter, priced in bytes:

InteractiveThe type & size detector — step through the literals
10 int 28 bytes 28 B — one int 24-byte header + one 4-byte digit
10 — int, 28 bytes
Same playlist, different price tags: text costs per character, a bool is a whole int underneath, and None is nearly free.
📏
Your bytes may vary
These sizes are from CPython on a 64-bit machine; another version or build may differ by a few bytes. The pattern is what to keep: every value pays a fixed object-header tax, then a per-content cost — and that tax is why chapter 8's typed arrays exist.

One loose thread is now impossible to ignore. We said the object is built first and the name seconds merely points at its address — so what happens with b = seconds? Two names, one object? Change one, does the other feel it? Chapter 3 follows the arrows: names, references, and what id() really reveals →

03Names & memory

Chapter 2 said every value is an object with an address. This chapter is the flip side: what a variable actually is — and it is not what most courses tell you. By the end you'll predict, before running a single line, whether changing b will also change a, and you'll watch Python's memory manager create, share, and destroy objects live.

A name is a sticky note, not a box

Run one line: seconds = 200 — the length of Levels by Avicii. It reads like "put 200 into a box called seconds." That is not what happens. Python executes it right-to-left, in three steps. Step 1: an int object holding 200 comes to exist somewhere in memory. Step 2: the name seconds is written into a table of names (the namespace). Step 3: = binds the name to the object — the sticky note lands. Name and object are separate things that met a moment ago, and id(seconds) reveals the meeting point: in CPython, that integer is literally the object's memory address. And because a note is just a note, it peels off cleanly: seconds = "3:20" moves the same name onto a str object. The name never had a type — objects do. That is the whole of "dynamic typing": labels are typeless, retargetable, and nearly free.

step 1 — build the object step 2 — write the name step 3 — = binds them int · 200 0x7f3a…9a08 exists — no name yet seconds a label, entered in the namespace table seconds int 200 0x7f3a…9a08 the sticky note lands
Fig — seconds = 200 runs right-to-left: object first, then the name, then the binding.
python
seconds = 200                # length of "Levels" by Avicii
print(id(seconds))           # 140724285964048 — an address (yours will differ)
print(type(seconds))         # <class 'int'> — the TYPE lives on the object

seconds = "3:20"             # peel the label off the int, stick it on a str
print(id(seconds))           # a completely different address
↺ The thing people get backwards
Most think a variable is a box that contains a value; it's really a label stuck onto an object that exists on its own. Boxes hold one thing each, and copying a box duplicates its contents. Labels just point — and nothing stops you from sticking twenty labels on one object.
✗ the box story (wrong) seconds 200 "the value lives inside the name" ✓ the label story (real) seconds int 200 0x7f3a…9a08 the label can move; the object stands alone
Fig — a variable stores an address, not a value — the value lives elsewhere, on its own.

If a label is just a pointer, nothing stops two labels landing on one object. What happens then? →

Two labels, one object: aliasing

An alias is a second name for the same object, and making one takes no special syntax — plain assignment does it. b = a copies nothing. It writes a second sticky note and slaps it on whatever a points to. Give the playlist two names and edit it through either: both "see" the change, because there was only ever one list. Two different acts are about to sit side by side in one short snippet, so name them now. Assignment rebinds: b = a today, or b = ["other", "songs"] tomorrow, just moves b's sticky note — a couldn't care less. But b.append(...) mutates: it edits the one shared object, and every label stuck to it sees the result. When you genuinely want an independent list, say so: b = a.copy() builds a new list object holding the same songs. For lists nested inside lists you need copy.deepcopy — a story for chapter 6.

python
a = ["Levels", "Wake Me Up"]
b = a                        # a second label — NOT a second list
print(id(a) == id(b))        # True: one object, two names

b.append("Hey Brother")      # edit the object THROUGH b…
print(a)                     # ['Levels', 'Wake Me Up', 'Hey Brother']  ← a "changed" too
a b list object 0 → 'Levels' 1 → 'Wake Me Up' id: 0x51f2a0 one object, one address — two names on it
Fig — b = a duplicates the label, never the list.

✗ The myth

b = a copies the list, so b is a safe backup of a.

✓ The reality

b = a writes a second sticky note on the same list. A real backup needs a new object: b = a.copy().

InteractiveAlias or copy? Append through b — see if a survives
a b list object 0 → 'Levels' 1 → 'Wake Me Up' id: 0x51f2a0 list object (the copy) 0 → 'Levels' 1 → 'Wake Me Up' id: 0x51f7c8
Alias = two notes, one list: b's edit reaches a. Copy = two lists: a is untouchable.
🧨
The classic "Python changed my data" bug
It's almost never a bug in Python. It's two sticky notes on one list: a single list handed around your program under different names, edited in one place, "mysteriously" different in another. Suspect it? Print id() of both names — matching addresses mean there was only ever one list.

So why didn't rebinding seconds drag other names along earlier? Because rebinding and editing are different acts — and some objects flat-out refuse to be edited →

Whiteboards and photos: mutable vs immutable

Every Python object is one of two kinds. A whiteboard can be wiped and rewritten in place: list, dict, set, bytearray. A photo is fixed the instant it's taken: int, float, str, tuple, bytes, frozenset. You cannot edit a photo — you can only shoot a new one. This is why title += " (Live)" looks like an edit but isn't: strings are immutable, so Python builds a brand-new str object and moves the label to it. The old photo keeps existing (until nothing points at it). A list's append, by contrast, really does rewrite the whiteboard: same object, same id, new contents. Now the memory trade-off snaps into focus. Immutables cost a fresh allocation per "change," but they're perfectly safe to share — no alias can vandalize a photo. Mutables are edited in place — cheap, but every label on the object feels every edit. The aliasing surprise from the last section only exists on whiteboards.

python
title = "Levels"
print(id(title))             # e.g. 2318762436976
title += " (Live)"           # strings can't be edited — Python builds a NEW one
print(id(title))             # different id: the label moved to a new object

queue = ["Levels"]
print(id(queue))             # e.g. 2318762841216
queue.append("Wake Me Up")   # lists CAN be edited
print(id(queue))             # same id: the object changed, the label never moved
whiteboard — mutable (list, dict, set) 'Levels' 'Wake Me Up' + 'Hey Brother' ← appended id: 0x51f2a0 — unchanged edits happen inside the object; the label never moves photo — immutable (int, str, tuple) str 'Levels' the old photo — never edited str 'Levels (Live)' title id: 0x9b30 — a NEW object "changing" an immutable = building a replacement
Fig — same id after a change = mutation; new id = a replacement object was built.
💡
The one-line lie detector
Change the thing, then compare id() before and after. Same id → you mutated a whiteboard. New id → you were holding a photo, and Python quietly built a replacement.

Every "change" to a photo leaves an abandoned object behind. RAM is finite. Something must be sweeping up — and it needs to know exactly when an object is abandoned →

Reference counting: the label census

Inside every CPython object's header sits a counter: how many references point at me right now? A reference is any arrow aimed at the object — a name, a slot in a list, an attribute of another object. Bind a new name and the count goes up. Remove an arrow — del a name, or rebind it to a different object — and the count goes down. (Every future way of gaining or losing a reference, and you'll meet many, ticks this same counter.) The moment it hits zero, the object is freed — instantly and deterministically, not "sometime later." Read that del line again, because it's widely misread: del track deletes the name, never the object. The object dies only when its last label disappears. Trace the playlist: track = [...] → count 1. alias = track → count 2 (two labels, one list). del track → count 1, the list lives on. del alias → count 0, memory reclaimed on the spot. sys.getrefcount lets you audit the census yourself — with one quirk worth knowing.

🔍
Why getrefcount always reads one high
To inspect the object, getrefcount must briefly hold it as its own argument — that's one extra reference during the call. A list with a single name reports 2: one real, one borrowed by the measuring tool.
python
import sys

track = ["Levels", "Avicii", 200]
print(sys.getrefcount(track))   # 2 → 1 real (track) + 1 borrowed by the call

alias = track
print(sys.getrefcount(track))   # 3 → track, alias, + the borrowed one

del alias                       # deletes the NAME alias — not the list
print(sys.getrefcount(track))   # 2 → back down

del track                       # last label gone → count 0 → freed instantly
track = [...] alias = track del track del alias refs: 1 refs: 2 refs: 1 refs: 0 track track alias alias (no names) the list the list the list the list freed
Fig — the census: 1 → 2 → 1 → 0. At zero, the memory is reclaimed immediately.
InteractiveRun the census yourself — bind and delete names
track alias fav mix tmp refs: 1 list object ['Levels', 'Wake Me Up'] addr 0x9a40 refcount 0 → freed — memory reclaimed
refcount = 1
Each name is +1. At exactly 0 the object dies — instantly. (getrefcount would read one higher: it borrows a reference to look.)
Wait —
run sys.getrefcount(2). Instead of a small number you get something absurd — millions on older Pythons, 4,294,967,295 on new ones. Who on earth is holding the number 2?

The small-integer shelf

Confession: back in the first section, seconds = 200 never built an int object. CPython builds the integers −5 through 256 once, at interpreter startup, and puts them on a shelf. Every time any code anywhere needs one of those values, it gets a label on the same shared object — no allocation, ever. Why those numbers? They're the workhorses: loop counters, list indexes, exit codes, song lengths. Building a fresh 28-byte object for every occurrence of 0 would be madness, so Python pre-pays once and shares forever. That's the answer to the curio: the interpreter, every imported module, and every list containing a 2 are all pointing at the one and only 2. (What the call actually reports is version-dependent: on older Pythons it's a live census of all those shared references; on 3.12+ it's a pegged "immortal" marker, not a count — the deeper cut below explains.) Numbers past the shelf — 257 and up — get a fresh object per construction. You can catch the shelf red-handed with id() — with one precaution. Type the literal 300 twice in the same chunk of code and Python's compiler may spot the duplicate and quietly reuse a single object for both, faking a shelf that isn't there. int("300") dodges that trick: the string is only turned into a number while the program runs, so any sharing you observe is the shelf's doing and nothing else.

python
import sys
print(sys.getrefcount(2))    # huge — the whole interpreter shares this one object

a = int("200")
b = int("200")
print(id(a) == id(b))        # True — 200 comes off the shelf: ONE object

c = int("300")
d = int("300")
print(id(c) == id(d))        # False — 300 is past the shelf: built fresh each time
built once at startup, shared forever — the small-int shelf (−5 … 256): -5 -4 -3 0 1 2 3 255 256 your code: n = 2 a module: i = 2 interpreter internals getrefcount(2) → enormous: everyone shares ONE object
Fig — −5…256 are pre-built singletons; every 2 in every program is the same 2.
⚠️
This is a CPython implementation detail
The shelf is an optimization, not a language rule — the exact range, and whether it exists at all, varies by Python implementation and version. Never write logic that depends on two equal numbers sharing an id. Compare values, not addresses.
The deeper cut

Three refinements. One: since Python 3.12 (PEP 683), shelf integers are immortal: their refcount field is pegged at a sentinel value and never actually ticks, which is why getrefcount(2) returns exactly 4,294,967,295 there — it's a flag, not a census. On older versions you get a large, jittery number instead (tens of thousands to millions, environment-specific — treat any figure you see as illustrative). Two: ints aren't the only shelf. CPython also caches (interns) short strings that look like identifiers, and sys.intern() lets you force-share any string — dict keys benefit. Three: this is exactly why is (chapter 4) misbehaves on numbers and strings as a value test: it answers "same object?", and the shelf makes that True for 200 yet False for 300. Same trap, now with a mechanism attached.

Refcounting frees objects the very instant they're orphaned — elegant, immediate, cheap. It has exactly one failure mode, and it's sneaky →

The blind spot: reference cycles

Refcounting has a rule it can't break: free at zero, only at zero. Now build two song nodes that point at each other — a.next = b, b.next = a — and delete both names. Run the census. Object a still has one reference: b.next. Object b still has one: a.next. Neither count is zero, so neither is freed — yet no name in your program can reach either of them. They're an unreachable island: two objects propping each other up in memory, forever, while refcounting stares at "1" and shrugs. Left alone, that's a memory leak. So CPython runs a second collector on top of refcounting: the cycle collector (the gc module), which periodically hunts for islands of objects only reachable from each other and frees them as a group. You almost never think about it — but gc.collect() forces a hunt right now and reports how many objects it reclaimed. Refcounting does the everyday work instantly; the cycle collector is the backstop for the one shape refcounting can't see.

python
import gc

class Node:
    def __init__(self, title):
        self.title = title
        self.next = None

a = Node("Levels")
b = Node("Wake Me Up")
a.next = b               # a holds a reference to b
b.next = a               # b holds one back to a — a cycle

del a
del b                    # both NAMES gone; each object still props up the other
print(gc.collect())      # > 0 — the cycle collector found the island, freed it
a (deleted) b (deleted) Node('Levels') refs: 1 Node('Wake Me Up') refs: 1 .next .next unreachable island — refcounting alone never frees it gc.collect() → finds the island, frees both
Fig — a cycle keeps both counts at 1 forever; only the cycle collector can free the island.
📝
You rarely call it yourself
CPython triggers the cycle collector automatically when object allocations pile up. Manual gc.collect() is for demos, tests, and the rare long-running process that builds cycles faster than the schedule catches them. Everything acyclic is still freed instantly by refcounting.

You now know every object has an identity as well as a value — two different questions with two different answers. Chapter 4 hands you the operators that interrogate both: == asks "same value?", is asks "same sticky-note target?" — plus the arithmetic where 7 / 3 refuses to give you an int →

04Operators & expressions

Chapter 3 gave you names pointing at objects. This chapter makes those objects do something. By the end you'll total a playlist, turn 602 seconds into 10:02, ask true/false questions that never lie, flip individual bits, and read any expression the exact way Python does — knowing which operator runs first.

An expression is a question; operators are the verbs

An expression is any piece of code that produces a value. 200 + 215 is an expression. So is total // 60. An operator is the symbol doing the combining — the verb of the sentence — and the values it acts on are its operands.

Python ships seven arithmetic operators: + - * / // % **. Four are school maths in keyboard costume — × is typed *, ÷ is typed /. ** is the power operator: 7 ** 3 is 7·7·7 = 343. The two genuine strangers, // and %, are both flavours of dividing — they get the next section to themselves.

python
levels = 200                  # "Levels" by Avicii, in seconds
total = levels + 215 + 187    # add "Titanium" and "Wake Me Up"
print(total)                  # 602
print(total * 2)              # 1204 — the playlist, played twice
print(7 ** 3)                 # 343

plays = 0
plays += 1                    # augmented assignment: plays = plays + 1
print(plays)                  # 1

That last operator, +=, is augmented assignment: read the value, add, rebind. And rebind is the exact word — an int is a photo, not a whiteboard (chapter 3), so += never edits the 0 object. Python computes 0 + 1 and moves the name onto the result — which here isn't even freshly built: it's the 1 already waiting on chapter 3's small-integer shelf. Past the shelf, the result would be constructed new. Either way plays lands on a different object: new value, new id().

one pair of numbers — seven questions + 7 + 3 10 - 7 - 3 4 * 7 * 3 21 / 7 / 3 2.33… // 7 // 3 2 % 7 % 3 1 ** 7 ** 3 343 the three divisions — why does one language need three?
Fig — Seven operators on the pair 7 and 3. Notice the odd one out: / is the only question answered with a decimal.

Three of those seven symbols are all some flavour of "divide". Why would a language need three? →

One slash, two slashes, and the leftover

"Levels" runs 200 seconds. Your music app shows 3:20. The gap between those two facts is exactly the gap between Python's three divisions.

/ is true division. It answers "exactly how many times?" — and it always returns a float. Even 6 / 3 is 2.0, not 2. No exceptions, no "only when it doesn't divide evenly". One slash: always a float.

// is floor division. It answers "how many whole times?" — divide, then round down to a whole number. 200 // 60 is 3: three complete minutes.

% is the remainder operator (say "mod"). It answers "what's left over after the whole times?" 200 % 60 is 20: twenty spare seconds.

// and % are a pair. One counts full blocks, the other keeps the leftover — together they account for every second, losing nothing.

python
total = 602                   # the whole playlist, in seconds
print(total / 60)             # 10.033333333333333 — exact ratio, a float
print(6 / 3)                  # 2.0 — still a float, even when it's clean
print(total // 60)            # 10 — whole minutes only
print(total % 60)             # 2  — seconds left over
print(f"{total // 60}:{total % 60:02d}")   # 10:02

Decoder for that last line: an f"..." string fills each {...} hole with the value of the expression inside it, and the :02d suffix says "print the number two digits wide; pad with a zero." That's what turns 2 into 02 — the clock reads 10:02, not 10:2.

"Levels" = 200 seconds 3:20 60 s 60 s 60 s 20 s 200 // 60 = 3 → counts the FULL blocks (minutes) 200 % 60 = 20 → the leftover (seconds) 200 / 60 = 3.3333… → the exact ratio, always a float
Fig — // counts whole blocks, % keeps the leftover, / gives the exact ratio. Three questions, three answers.
💡
Two answers, one call
divmod(602, 60) returns both at once: the tuple (10, 2). Whole part and remainder, computed together.
InteractiveDrag the song length — watch // and % split it
200 // 60 = 3 full minutes 200 % 60 = 20 s left over 3:20 each green block = 60 s (one full minute) · amber = the % remainder
200 s → 3:20
// counts whole blocks; % is what's left. Together they never lose a second.
The deeper cut

What about negatives? // floors — it always rounds toward negative infinity, not toward zero. So -7 // 2 is -4 (not −3), and -7 % 2 is 1: in Python the remainder takes the divisor's sign. This isn't arbitrary — it preserves the invariant a == b * (a // b) + a % b for every pair: 2 * (-4) + 1 == -7. C and JavaScript truncate toward zero instead, so their -7 % 2 is -1. Same symbols, different contract — worth knowing before you port code.

Numbers answered with numbers. The next family of operators answers every question with one of just two words →

Two kinds of equal: == and is

The comparison operators — == != < > <= >= — each ask a question and always answer with a bool: True or False. Nothing in between.

7 == "7" is False. Python never quietly converts one type to force a match — an int and a str are different objects with different memory layouts (chapter 2), so they're simply not equal. Likewise 1 != "1" is True.

Strings compare by code point — the number behind each character (ord('A') is 65). "Avicii" < "Daft Punk" is True because 65 < 68, decided at the first differing character; the rest is never even read. And "Apple" > "Orange" is False (65 < 79).

Then there's is — and it is not a fancy ==. == asks "same value?" is asks "same object?" — literally, the same address that id() exposed in chapter 3.

python
a = [200, 215, 187]     # one playlist
b = [200, 215, 187]     # a second, identical playlist
c = a                   # a second NAME for the first one
print(a == b)           # True  — same contents
print(a is b)           # False — two objects, two addresses
print(a is c)           # True  — one object, two labels
a c b id: 0x1f…a10 [200, 215, 187] id: 0x1f…b58 [200, 215, 187] a == b → True (same contents) a is b → False (two objects)
Fig — Equal value, two addresses. == reads the contents; is reads the label on the object.
"Avicii" A v i c ord: 65 "Daft Punk" D a f t ord: 68 65 < 68 → decided! the remaining characters are never compared "Avicii" < "Daft Punk" → True
Fig — String comparison stops at the first character that differs. One code point settles the whole match.
⚠️
Never use is on numbers or strings
CPython pre-builds the integers −5 to 256 as shared singletons (chapter 3), so x is 200 can come back True by pure caching accident — and False for 1000. It's an implementation detail, not a promise; modern Python even emits a SyntaxWarning when you try is with a literal. Reserve is for one job: x is None.
🔗
Chained comparison
Python lets you write 180 <= seconds <= 240 — it means 180 <= seconds and seconds <= 240, with seconds evaluated once. Most languages can't do this; Python reads it like maths.
Wait —
if == checks value, why is 0.1 + 0.2 == 0.3 False? Because floats are base-2 approximations (chapter 2's bytes strike again) — the sum lands a hair off 0.3. Value equality is honest; the values themselves were never exact.

== hands you a clean True or False. But Python will happily accept values that are neither — and judge them anyway →

Truthiness, and the logic that stops early

Every Python value can stand in for True or False; bool(x) reveals the verdict. Why would a language judge every value? Because real questions rarely arrive as booleans — they arrive as "is the queue empty?" or "did the user actually type a name?" — and Python lets the queue or the string answer for itself. The rule fits in one line: empty or zero is falsy; everything else is truthy. The complete falsy lineup: False, None, 0, 0.0, "", [], {}, set(). That's essentially it — every non-empty string, non-zero number, and non-empty collection is truthy.

and, or, not combine these verdicts. not flips one. a and b is truthy only if both are; a or b if at least one is. But the mechanism is the real lesson: they are lazy. Python evaluates left to right and stops the moment the answer is settled — this is short-circuit evaluation. In False and anything, the anything never runs. In True or anything, same.

That's not trivia; it's a guard you'll use forever:

python
total, count = 602, 3
print(count != 0 and total / count > 180)   # True — average is 200.66…

count = 0
print(count != 0 and total / count > 180)   # False — division NEVER runs

When count is 0, the left side is False, the whole and is settled, and total / count — the crash — never executes. The skipped side costs nothing: zero computation, zero memory. Short-circuiting is how you put the cheap check in front of the expensive (or dangerous) one.

FALSY — the empty and the zero (all eight) False None 0 0.0 "" [] {} set() TRUTHY — everything else 42 -1 "0" " " [0] "False" ⚠ "0" and "False" are non-empty strings — truthy. Only "" is falsy.
Fig — The falsy eight. If it's not on the red row, Python treats it as true.
count = 0 count != 0 → False and total / count > 180 never evaluated — no crash, no cost result: False — settled by the left side alone
Fig — and stops at the first falsy operand. The dangerous half of the expression is never touched.
↺ The thing people get backwards
Most think bool("0") and bool("False") are False; it's really True for both. Truthiness never reads the meaning of a string — only whether it's empty. "0" is one character long, so it's truthy. The only falsy string is "".
The deeper cut

Strictly, and and or don't return True/False — they return one of their operands, unconverted. a or b returns a if it's truthy, else b; a and b returns a if it's falsy, else b. With bool operands the result happens to be a bool, which is why beginners never notice. But it powers a classic idiom: name = title or "Untitled" — if title is "", you get "Untitled"; otherwise you get title itself. Only not always returns a genuine bool.

and/or judge a value as one whole. But chapter 0 promised operators that reach inside the value — down to the individual bits →

Down to the bits: & | ^ ~ << >>

Chapter 0 showed that an int is bits: 12 is 1100, 10 is 1010. Six operators work directly on those bits, column by column: & (AND), | (OR), ^ (XOR), ~ (NOT), and the shifts << >>.

Stack 12 over 10 and read each column. & keeps a 1 only where both rows have 1 → 1000 = 8. | where at least one does → 1110 = 14. ^ where exactly one does → 0110 = 6.

Shifts slide every bit sideways. x << 1 doubles; x >> 1 halves (flooring): 200 >> 1 is 100. Same trick as appending or dropping a zero in base 10 — just in base 2. The flipper ~ hides a surprise: flipping 1100 looks like it should give 0011, which is 3 — yet ~12 is -13. The catch: a Python int drags an endless train of leading 0s in front of the bits you see, and ~ flips every one of them to 1s. An endless run of leading 1s is exactly how binary writes a negative number, and the result always lands on ~x == -x - 1.

Memory angle: nothing new is built here. No container, no copying — the result is just another int, computed from bits the operands already had, in single CPU instructions. That's why bitwise ops are the fastest thing a processor does.

python
print(12 & 10)    # 8  — 1100 AND 1010 = 1000
print(12 | 10)    # 14 — 1100 OR  1010 = 1110
print(12 ^ 10)    # 6  — 1100 XOR 1010 = 0110
print(200 >> 1)   # 100 — shift right once = halve
print(5 << 2)     # 20  — shift left twice = ×4
print(~12)        # -13 — flip every bit: ~x == -x - 1
8 4 2 1 a = 12 1 1 0 0 b = 10 & 1 0 1 0 a & b = 8 1 0 0 0 only column where BOTH are 1
Fig — 12 & 10 = 8. Each output column depends only on its own two input bits.
InteractiveTwo 4-bit numbers, one operator — watch the columns
8 4 2 1 a = 12 0 0 0 0 0b1100 b = 10 & 0 0 0 0 0b1010 a & b = 8 0 0 0 0 0b1000
12 10
& needs both 1s in a column, | needs at least one, ^ needs exactly one. Columns never talk to each other.
🚩
What bits are actually for: flags
Give each song property one bit: EXPLICIT, DOWNLOADED, LOVED = 1, 2, 4. Pack them with OR: song = EXPLICIT | LOVED5 (0b101). Test with AND: song & LOVED4, truthy — the flag is set; song & DOWNLOADED0, falsy. Three yes/no facts stored in one small int — this is how file permissions and network protocols pack options.

✗ The myth

| and & are just symbols for or and and. You'll see code like (x == "Y") | (x == "y") that seems to prove it.

✓ The reality

They're bit-column operators that happen to coincide on plain bools. Differences that bite: they evaluate both sides always (no short-circuit — the crash-guard from the last section fails), and they bind tighter than ==, which is why that code needed its parentheses at all. Logic: and/or. Bits: &/|.

You now own a dozen-plus operators. Stack several in one line and a new question appears: who runs first? →

Who binds first: precedence

2 + 3 * 4 is 14, not 20, because * binds tighter than +. Every operator has a rank — its precedence — and Python groups the tightest ranks first, consulting the same ladder every time. You don't memorise all of it; you memorise the shape and the three surprises.

Surprise one: ** outranks unary minus, so -2 ** 2 is -(2 ** 2) = -4. Surprise two: the bitwise family sits above comparisons — the final nail in the |-as-or myth, since x == 1 | y == 2 groups the | before either ==. Surprise three: not beats and, which beats or — so True or False and False is True, because the and runs first.

python
print(2 + 3 * 4)                 # 14 — * before +
print((2 + 3) * 4)               # 20 — parentheses rearrange the tree
print(-2 ** 2)                   # -4 — ** before unary minus
print(True or False and False)   # True — and before or
binds TIGHTEST — grouped first ( ) parentheses ** power -x ~x unary minus, bit-flip so -2**2 = -4 * / // % + - << >> shifts & ^ | bitwise sits ABOVE comparisons! == != < <= > >= is in not and or and before or binds LOOSEST — grouped last
Fig — The precedence ladder. Higher rungs group first; when in doubt, add parentheses and stop doubting.
🪜
One more quirk
Chained ** groups right-to-left: 2 ** 3 ** 2 is 2 ** 9 = 512, not 8 ** 2. It's the only arithmetic operator that does this.
💡
Parentheses are free
Grouping is resolved when your source is compiled to bytecode (chapter 0) — parentheses add zero work at runtime. (total / count) > 180 costs exactly what the bare version costs, and every future reader thanks you. Spend them freely.

An expression computes a value — but it never decides anything. Staple a decision to it — if seconds > 180: — and your code finally branches, repeats, and reacts. That's chapter 5 →

05Control flow

Until now, every program you've written runs top to bottom, once, the same way every time. This chapter gives your code two new abilities: deciding (run this line or skip it) and repeating (run this line a thousand times). By the end you'll walk a whole playlist with a loop, total its seconds, pair two lists in parallel, and know exactly which line of a program runs next — always.

A program that chooses: if / elif / else

An if statement asks a question and runs a block of code only when the answer is truthy — chapter 4's rule: "", 0, None, and empty collections count as False; almost everything else counts as True. The block is everything indented under the if line. This is the payoff of chapter 2's third axiom: indentation is structure. The interpreter doesn't see curly braces; it sees four spaces and concludes "this line belongs to that condition."

Three keywords, one chain: if tests first. elif ("else if") tests only when everything above it failed. else catches whatever's left. And here is the rule nobody states out loud: only the first matching branch runs. Once one condition is True, Python executes that block and jumps past the entire chain — the later conditions are never even evaluated.

python
seconds = 200          # "Levels" by Avicii

if seconds > 600:
    label = "epic"
elif seconds > 180:
    label = "standard"     # <-- this one runs
elif seconds > 0:
    label = "short"        # True too — but never checked
else:
    label = "empty?"

print(label)               # standard
seconds = 200 seconds > 600 ? False → fall through seconds > 180 ? True label="standard" seconds > 0 ? (skipped) else (skipped) jump past the chain
Fig — One chain, one exit: the first True condition wins and everything below it is never checked.
⚠️
Indentation is not decoration
Move a line one indent left and it leaves the block — it now runs unconditionally. That's not a style bug, it's a different program. Pick 4 spaces (the standard) and never mix tabs with spaces.

That leaves three shapes to choose from — nested ifs, elif chains, and back-to-back separate ifs — and here's the rule for picking. Nest when one question only makes sense after another: the bigger picture goes outside. Chain with elif when you're sorting one value into ranges — exactly one bucket applies, like the song-length labels above. Write separate ifs when a check applies to everyone who got this far. Think roller coaster: "tall enough?" is the outer if; "child or adult ticket?" only matters once you're in — nested; "want the ride photo?" applies to every rider — a sibling if at the outer level. An elif there would be a bug: it would skip the photo question for anyone who matched a branch above.

python
seconds = 200
explicit = False

if seconds > 0:                # big picture: is this a real track?
    if seconds > 180:          # only meaningful for real tracks
        print("radio edit? maybe trim it")
    else:
        print("fits a radio slot")

    if not explicit:           # sibling: applies to EVERY real track
        print("safe for the party playlist")
NESTED if big_picture: if detail: ... inner runs only if outer was True ELIF CHAIN if range_1: ✓ elif range_2: ✗ else: ✗ exactly ONE runs SEPARATE IFS if check_A: ✓ if check_B: ✓ independent checks — both can run depends-on → nest · one-of-many → elif · applies-to-all → separate ifs
Fig — Three shapes, three meanings: nesting encodes dependence, elif encodes exclusivity, separate ifs encode independence.
Wait —
an if decides once. What if you want the same decision made for every one of 500 songs? You'd need the code to come back around…?

The for loop: do this for each one

A for loop takes an iterable — anything that can hand out its items one at a time: a string hands out characters, a list hands out elements — and runs its indented block once per item. Each turn of the loop is called an iteration. The name after for is the loop variable: a plain name (chapter 3: a label, not a box) that gets re-bound to the next item at the top of every iteration.

Pair it with the accumulator pattern — start a total at zero, add to it each turn — and you can answer real questions about the playlist:

python
lengths = [200, 245, 176, 305, 222]   # seconds per song

total = 0                  # the accumulator
for seconds in lengths:
    total += seconds       # runs 5 times

print(total)               # 1148
print(seconds)             # 222 — still alive!

That last line surprises everyone: the loop variable survives the loop. It isn't scoped to the block; it still holds the last value it was bound to. Handy for debugging, dangerous if you reuse the name assuming it's fresh.

seconds one name, re-bound each iteration 200 245 176 305 222 lengths — the list being walked after the loop: still bound to 222
Fig — The loop variable is one label hopping across the list — and when the loop ends, it's still stuck on the last item.

Watch the whole thing run — drag the slider to step through the loop one iteration at a time.

InteractiveStep through the loop — watch the total grow
for seconds in lengths: # playlist total (accumulator) total = 0 before the loop: nothing has run yet
0 of 5
One slider step = one iteration: bind the next item, run the block, add to the total. After step 5 the loop ends — and seconds still holds 222.

Fine for a list you already have. But what about "do this 1,000,000 times" — do you have to build a million-element list first? →

range(): counting without storing the numbers

range() makes a counting sequence. range(5) counts 0, 1, 2, 3, 4 — the stop value is exclusive, always. range(1, 6) counts 1 through 5. range(1, 11, 2) adds a step: 1, 3, 5, 7, 9. Three knobs: start, stop, step.

Here's the memory story, and it matters. A list of a million numbers is a million objects sitting in RAM. A range is not a list — it's a tiny object that just remembers its three knobs and computes each number the moment the loop asks for it. That strategy is called lazy: produce values on demand instead of storing them all up front. On a 64-bit CPython, sys.getsizeof(range(1_000_000)) is 48 bytes — the same 48 bytes as range(10) — while the equivalent list costs about 8 MB before you count the numbers themselves.

python
for track_no in range(1, 6):
    print("now playing track", track_no)   # 1 2 3 4 5

import sys
print(sys.getsizeof(range(1_000_000)))     # 48 — three knobs, no numbers
print(sys.getsizeof(list(range(1_000_000))))  # ~8,000,056 — a million slots
range(1_000_000) — lazy start=0 stop=1000000 step=1 48 bytes, total next number computed on demand: current + step list(range(1_000_000)) — eager 0 1 2 3 999999 …999,996 more slots… ~8 MB just for the slots every element exists in RAM right now
Fig — A range stores the recipe (48 bytes); a list stores every meal. Laziness is why huge loops are cheap.
The deeper cut
A range is better than lazy — it's a full sequence. It supports len(), indexing (range(0, 100, 5)[3] → 15, computed as start + 3*step), slicing, and an O(1) membership test: 999_999 in range(1_000_000) does arithmetic, not a million-step scan. That's stronger than a generator (chapter 10), which can only be consumed forward, once. Also note range(10, 0) is empty — with a positive step, start must be below stop; count down with range(10, 0, -1).

Looping over songs gives you each song. Looping over range gives you each number. What if you need both at once — the song and its position? →

enumerate() and zip(): positions and parallel lists

The beginner move for "item plus its position" is for i in range(len(songs)), then songs[i] — square brackets after a list mean "hand me the item sitting at position i", counting from 0 (chapter 6 owns the full story). It works, but Python has a cleaner tool. enumerate(songs) hands back a pair — an (index, value) tuple — on every turn, and you unpack it right in the loop header. Need track numbers starting at 1 for humans? enumerate(songs, start=1).

python
songs = ["Levels", "Wake Me Up", "Titanium"]

for n, title in enumerate(songs, start=1):
    print(n, title)
# 1 Levels
# 2 Wake Me Up
# 3 Titanium

zip() solves the parallel-lists problem: titles in one list, artists in another, related only by sitting at the same position. Zip them and each turn hands you one tuple with the matching pair — position 0 with position 0, 1 with 1 — like the two halves of a zipper meeting tooth by tooth. If the lists differ in length, zip stops at the shorter one — silently, no error; the extra teeth simply never engage. And both tools are lazy like range: they build each tuple on demand, never a big list of pairs in memory.

python
titles  = ["Levels", "Wake Me Up", "Titanium"]
artists = ["Avicii", "Avicii", "David Guetta", "Daft Punk"]  # one extra

for title, artist in zip(titles, artists):
    print(title, "—", artist)
# Levels — Avicii
# Wake Me Up — Avicii
# Titanium — David Guetta        (Daft Punk never appears: truncated)

print(dict(zip(titles, artists)))
# {'Levels': 'Avicii', 'Wake Me Up': 'Avicii', 'Titanium': 'David Guetta'}
titles (3) Levels Wake Me Up Titanium 🤐 artists (4) Avicii Avicii David Guetta Daft Punk ✂ dropped ('Levels','Avicii') ('Wake Me Up','Avicii') ('Titanium','David Guetta') zip pairs by position, lazily — and stops at the shortest list
Fig — zip walks two lists in lockstep and truncates to the shorter — the fourth artist is silently left out.
💡
Spot the pattern
If you ever write songs[i] inside a for i in range(len(songs)) loop, stop: you want enumerate. If you're indexing two lists with the same i, you want zip.

for loops end when the items run out. But some loops can't know in advance how many turns they need — "keep asking until the answer is valid"… →

while: loop until the world changes

A while loop doesn't walk a collection — it re-checks a condition before every turn and runs while the condition is truthy. (Read while as "as long as", never as "until" — write the condition for "until" and your loop is exactly inverted. That one backwards word is the classic first while-loop bug.) The moment the check comes up falsy, the loop is skipped and execution continues below it. That means something in the body must eventually change the condition, or you've written an infinite loop — Ctrl+C is the emergency exit.

python
queue = ["Levels", "Wake Me Up", "Titanium"]
battery = 5                       # percent

while battery > 0 and queue:      # truthiness: [] is falsy
    song = queue.pop(0)           # take the next song (details: ch.6)
    battery -= 2
    print("playing", song, "| battery", battery)

# playing Levels | battery 3
# playing Wake Me Up | battery 1
# playing Titanium | battery -1

Note the condition uses chapter 4's tools twice: a bare queue is an empty-check (an empty list is falsy), and and short-circuits. The third song drains the battery to −1, so the very next check fails at battery > 0 — and thanks to short-circuiting, queue is never even looked at.

battery > 0 and queue ? run the body (pop, drain, print) truthy loop back, check again falsy → exit, continue below
Fig — A while loop is check → body → check → body… — it runs while the check is truthy, and only ever exits at a check.

✗ The myth

"Use for for short loops and while for long ones."

✓ The reality

It's about knowledge, not length. Know what you're iterating over (a list, a range, a string)? Use for. Only know the stopping condition ("until input is valid", "until the queue drains")? Use while.

Either loop can hit a moment mid-turn where you think "we're done" or "skip this one." Two keywords handle exactly that →

break, continue — and the else nobody knows

break abandons the loop immediately — no more iterations, execution jumps below the loop. continue abandons only the current turn: it skips the rest of the body and goes straight to the next iteration. Picture a bouncer at the top of the loop body: continue turns this one item away at the door, so the code below it only ever meets items that passed the check. Together the pair unlocks Python's favourite "ask until it's right" shape: while True: — a deliberately endless loop, because you can't know in advance how many tries the user will need — with a break as the exit door the moment the answer is valid.

python
songs   = ["Levels", "Wake Me Up", "Strobe", "Titanium"]
lengths = [200, 245, 176, 222]

target = "Strobe"
for title, seconds in zip(songs, lengths):
    if seconds < 180:
        continue            # too short — skip to the next song
    if title == target:
        print("queued:", title)
        break               # found it — stop searching
else:
    print(target, "not on this playlist")   # runs only if NO break fired

Trace it: Strobe is 176s, so the continue skips it — the break never fires — and the else prints Strobe not on this playlist. That's for-else: the else block runs only when the loop finishes all its iterations without breaking. Read it as "no-break". It replaces the clunky found = False flag pattern in search loops.

for song in playlist: …body… continue ── next turn break ──── leave now back to the top skips else else: # no break loop ran to the end after loop
Fig — continue loops back, break bails out — and the loop's else runs only on the green "no break" path.
↺ The thing people get backwards
Most think a loop's else runs "if the loop was empty" or "if it failed somehow"; it's really the no-break clause — it runs whenever the loop completes without break, including the zero-iteration case. If a break fires, the else is skipped. Mentally rename it nobreak and it never confuses you again.

One interactive to lock in the difference — the same playlist scan with a rule you control:

InteractiveMove the cutoff — watch break vs continue change the path
rule: if seconds < cutoff → continue (skip) · if seconds > 300 → break (stop)
180
Skipped songs (cyan) cost a turn but no work; a red song ends the whole loop. Push the cutoff past 305 and even the long song gets skipped — no break, so the else finally runs.
📝
Order matters inside the body
Notice the checks run in written order each turn: the continue test comes first, so at cutoff 310 the 305-second song is skipped before the break test can see it — no break, else runs. Swap the two ifs and the outcome flips. A loop body is still just top-to-bottom code, once per turn.

You can now decide and repeat — but your loops walk lists you typed by hand. Lists have their own machinery: how they grow, why append is instant but inserting at the front is slow, and what a slice really copies. That's chapter 6 →

06Sequences: list & tuple

You know from chapter 3 that a name is a label on one object. This chapter opens up Python's workhorse container — the list — and shows you its actual shape in RAM. By the end you'll be able to predict the cost of any list operation before you run it: why append is instant, why insert(0, …) charges a tax, and what a slice, a tuple, and a starred unpacking really do to memory.

A list is a row of numbered slots

Since chapter 0, memory is numbered boxes. A list is CPython's dynamic arrayarray because it rents a run of consecutive boxes (call them slots), dynamic because it can grow, which the next section catches in the act. On a 64-bit machine each slot is exactly 8 bytes wide. What sits in a slot is not the title text — and certainly not the song itself (a title is a few dozen bytes of str; the audio behind it would be megabytes). It's a reference: the address of the title's str object, which lives somewhere else entirely.

python
playlist = ["Levels", "Wake Me Up", "Hey Brother"]

Because every slot holds an address, every slot is the same size. That's why one list happily mixes a str, an int, and another list: the container is uniform; the contents are anywhere.

playlist list object · len 3 · capacity 4 spare 0x5000 0x5008 0x5010 0x5018 each slot = 8 bytes: one reference (an address) str object 'Levels' @ 0x7A40 str object 'Wake Me Up' @ 0x82C8 str object 'Hey Brother' @ 0x9B10 the heap — objects live wherever there's room
Fig — The list is one solid block of 8-byte references, sitting shoulder to shoulder; the title strings they point to are scattered across the heap.
↺ The thing people get backwards
Most think a list stores your values inside itself; it really stores 8-byte directions to them. A list of enormous strings is itself tiny — 8 bytes per entry. And when a list is copied or regrown, the songs never move; only the directions get recopied.

But those slots must be contiguous — touching. So what happens when you append a fourth song and the box next door is already rented to someone else? →

Spare seats: why append is almost free

CPython plans ahead: when a list grows, it rents more slots than it needs. That's overallocation — spare seats at the end of the block. append just drops one reference into the next spare seat: constant time, no matter how long the list is. Watch the spare capacity with sys.getsizeof, which reports the container's own bytes — its header (a fixed block of bookkeeping: the list's length, its capacity, where its slots start) plus the slots themselves, never the songs:

python
import sys

playlist = []
print(sys.getsizeof(playlist))      # 56   (just the header, zero slots)

for title in ["Levels", "Wake Me Up", "Hey Brother",
              "Waiting For Love", "The Nights"]:
    playlist.append(title)
    print(len(playlist), sys.getsizeof(playlist))

# 1 88     grew once: 56 + 4 slots x 8 bytes
# 2 88     spare seat used - no growth
# 3 88
# 4 88
# 5 120    full again: regrew to 8 slots (56 + 64)

(CPython 3.12, 64-bit. Exact bytes shift between versions; the staircase shape doesn't.) When the spares run out, CPython rents a bigger block somewhere else, copies the references over, and frees the old block. That copy costs O(n) — but it's rare, so averaged over many appends the cost per append is constant: amortized O(1). "Amortized" just means total cost divided by number of operations: the list moves to a bigger apartment once, then coasts on the spare rooms for ages — spread across all those cheap appends, moving day barely shows up in the average.

before — capacity 4, all 4 slots used Levels Wake Me… Hey Bro… Waiting… @ 0x5000 — freed after the move .append("The Nights") no spare slot left! copy 4 references — the songs themselves don't move after — new block at a new address, capacity 8 Levels Wake Me… Hey Bro… Waiting… The Nig…new spare spare spare @ 0x9000 one regrow per many appends → averaged out, append is O(1) amortized
Fig — Growing = rent a bigger block elsewhere, copy the references, free the old block. It's rare — most appends just land in a spare seat.
The deeper cut
CPython's list_resize requests roughly newsize + (newsize >> 3) + 6, rounded down to a multiple of 4 — about 12.5% headroom plus a constant. The capacity sequence under repeated appends: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76… Proportional growth is the whole trick: each element gets copied only a bounded number of times across all regrows, so n appends cost O(n) total — O(1) each. Two footnotes: a literal like ["a","b","c"] measures 88 bytes on CPython 3.12 (56 + 4×8 — even a literal reserves a spare slot here; an exact-fit 80-byte block comes from a copy like playlist[:]); and all of this is a CPython implementation detail. The language itself only promises that append is amortized O(1).

Contiguous slots, 8 bytes each, starting at one base address… that arithmetic sounds exploitable. Can Python jump straight to slot 764,213 without looking at the first 764,212? →

Indexing is a multiplication, not a search

Because the slots are contiguous and identical in size, the address of any slot is pure arithmetic:

address(i) = base + i × 8

For playlist[3] with the block at 0x5000: slot 3 starts 3 × 8 = 24 bytes past the base, so 0x5000 + 24 = 0x5018. One multiply, one add, one jump. No walking, no counting, no scanning slots 0–2. The cost is identical for playlist[0] and playlist[999999] — that's what O(1) means: cost independent of n. This property is called random access, and it's the payoff for keeping the block contiguous.

python
playlist = ["Levels", "Wake Me Up", "Hey Brother",
            "Waiting For Love", "The Nights"]

print(playlist[3])      # Waiting For Love   (one jump, zero scanning)
print(len(playlist))    # 5
📏
len() is O(1) too
The list's header stores its own count (you saw len 3 in the diagrams). len(playlist) reads that number — it never walks the slots.
address(i) = base + i × 8 address(3) = 0x5000 + 24 = 0x5018 — one jump 0 1 2 3 4 5 6 7 Levels Wake Me… Hey Bro… Waiting… The Nig… Silhou… Addict… Broken… 0x5000 0x5008 0x5010 0x5018 0x5020 0x5028 0x5030 0x5038 same three machine steps whether i = 0 or i = 999,999
Fig — Indexing computes the slot's address and jumps straight there — no walking, whatever i is.
InteractiveSlide the index — watch the address get computed
address = 0x5000 + 3 × 8 = 0x5018 steps: always 3 0 1 2 3 4 5 6 7 Levels Wake Me… Hey Bro… Waiting… The Nig… Silhou… Addict… Broken… 0x5000 0x5008 0x5010 0x5018 0x5020 0x5028 0x5030 0x5038 playlist[3] → 'Waiting For Love'
i = 3
The cost never changes with i: one multiply, one add, one jump — O(1).

Jumping to a slot is free. But making room between two slots — where does the new reference physically go? →

The middle charges rent

Contiguity giveth and contiguity taketh away. There is no gap between slot 0 and slot 1 — so to insert at position i, every reference from slot i to the end must move one slot right first. That's n − i moves. Insert at the front: the whole list shifts, O(n). Insert at the end: zero moves, O(1). Deleting mirrors it exactly — the tail shifts left to close the gap.

python
playlist.append("Silhouettes")    # O(1): drops into a spare end slot
playlist.insert(0, "The Days")    # O(n): every song shifts one slot right
playlist.pop()                    # O(1): 'Silhouettes' leaves, nothing moves
playlist.pop(0)                   # O(n): 'The Days' leaves, everything shifts back
playlist.insert(1, "The Days") before Levels Wake Me Up Hey Brother Waiting F… The Nights after Levels The Days Wake Me Up Hey Brother Waiting F… The Nights 4 of the 5 songs had to move — cost = n − i shifts
Fig — Everything after the insertion point moves one slot right; the closer to the front you strike, the more you pay.
InteractivePick an insert position — count the shifts
cost = n − i = 8 − 3 = 5 → O(n) 5 shifts Levels Wake Me… Hey Bro… Waiting… The Nig… Silhou… Addict… Broken… 0 1 2 3 4 5 6 7 red slots must each move one step right before the new song fits
i = 3
Cost = n − i. At the front, every song shifts; at the end, none do — that free case is append.
🔥
The O(n²) trap
Calling insert(0, x) or pop(0) inside a loop shifts the whole list every iteration — n operations × n shifts ≈ n². For 100,000 songs that's billions of moves. A structure built to be cheap at both ends exists: deque, chapter 8.
operation what actually happens cost playlist[i]jump to base + i × 8O(1) playlist[i] = xoverwrite one referenceO(1) playlist.append(x)drop into a spare end slotO(1)† playlist.pop()remove the last — nothing shiftsO(1) playlist.insert(i, x)shift n − i references rightO(n) pop(i) · remove(x)shift the tail left to close the gapO(n) len(playlist)read the count stored in the headerO(1) x in playlistwalk the slots one by one, comparingO(n) † amortized — the occasional regrow, averaged across all appends
Fig — The ends are cheap, the middle pays the shift, and searching by value pays the walk.
Wait —
playlist[500000] is instant, but "Levels" in playlist crawls through every slot. Why is finding by position free while finding by value costs a full walk — and could any structure do better?

Shifting, copying, costs per slot… so what actually happens when you ask for a piece of a list? →

A slice is a photocopy of a stretch

playlist[a:b] builds a new list and copies the references from slot a up to — but not including — slot b. Stop-exclusive, exactly like range in chapter 5. A third number sets the step, and a negative step walks backwards:

python
playlist = ["Levels", "Wake Me Up", "Hey Brother",
            "Waiting For Love", "The Nights"]

print(playlist[1:3])    # ['Wake Me Up', 'Hey Brother']       stop 3 excluded
print(playlist[:2])     # ['Levels', 'Wake Me Up']
print(playlist[::2])    # ['Levels', 'Hey Brother', 'The Nights']
print(playlist[::-1])   # ['The Nights', 'Waiting For Love', 'Hey Brother',
                        #  'Wake Me Up', 'Levels']             reversed copy
print(playlist[-1])     # The Nights

The cost is honest: copying k references takes O(k) time and O(k) new memory. The original is untouched — a slice never mutates.

🎯
Negative indices
playlist[-1] is shorthand for playlist[len(playlist) - 1]: count backwards from the end. -1 is the last song, -2 the one before it. Still O(1) — it's the same address arithmetic after one subtraction.
from the front: 0 1 2 3 4 Levels Wake Me Up Hey Brother Waiting F… The Nights from the end: -5 -4 -3 -2 -1
Fig — Every slot answers to two names: its offset from the front, and its distance from the end.
playlist Levels Wake Me… Hey Bro… Waiting… The Nig… top2 = playlist[0:2] 2 references copied → O(k) 'Levels' str @ 0x7A40 'Wake Me Up' str @ 0x82C8 top2 Levels Wake Me… a new block of references — pointing at the very same songs
Fig — The slice rents a fresh block and copies the references; the songs are never duplicated — a shallow copy.
⚠️
Shallow means shared
A slice copies references, not objects. With immutable strings that's harmless. But slice a list of lists and both copies point at the same inner lists — mutate one and the "copy" changes too. That's chapter 3's whiteboard aliasing wearing a new hat.

A slice hands you a safe copy because someone might mutate the original. But what if a record should never be mutable in the first place? →

Tuples: the sealed track, and how to crack one open

A tuple is an immutable sequence: same contiguous slots, same O(1) indexing, same slicing — but the block is sealed at birth. No append, no spare capacity, no regrows ever. That buys three things: an exact-fit block (no spare seats to pay for), a leaner header (40 bytes of bookkeeping instead of the list's 56 — a sealed block has no capacity to track), and hashability — the right to be a dictionary key, unpacked in the callout at the end of this chapter. Concretely, the track ("Levels", "Avicii", 200) weighs 40 + 3×8 = 64 bytes; the same three fields as a list built by appends weigh 88.

track = ("Levels", "Avicii", 200) tuple · len 3 · no spare — sealed → 'Levels' → 'Avicii' → 200 40 + 3×8 = 64 bytes same data as a list (built by 3 appends) list · len 3 · capacity 4 → 'Levels' → 'Avicii' → 200 spare 56 + 4×8 = 88 bytes exact fit + smaller header + immutable → and hashable
Fig — A tuple is an exact-fit, sealed block: smaller than the equivalent list, and it can never need to regrow.

✗ The myth

The parentheses make the tuple: (4) is a tuple with one item.

✓ The reality

The comma makes the tuple. (4) is just the int 4 in grouping parens; 4, and (4,) are tuples. track = "Levels", "Avicii", 200 is a tuple with no parens at all.

Building a tuple from loose values is called packing; naming its parts is unpacking — and a star scoops up whatever the plain names don't claim:

python
track = "Levels", "Avicii", 200        # packing: the commas build a tuple
title, artist, seconds = track          # unpacking: three names, three slots
print(artist)                           # Avicii

a, b = "Levels", "Stories"
a, b = b, a                             # swap: pack the right, unpack the left
print(a)                                # Stories

first, *rest = playlist
print(rest)     # ['Wake Me Up', 'Hey Brother', 'Waiting For Love', 'The Nights']

first, *middle, last = playlist
print(middle)   # ['Wake Me Up', 'Hey Brother', 'Waiting For Love']

Rules of the star: only one starred name per unpacking (Python couldn't split the leftovers two ways), it can sit anywhere in the row, and it always collects into a new list — even when you unpack a tuple.

first, *middle, last = playlist Levels Wake Me Up Hey Brother Waiting F… The Nights first *middle last ['Wake Me Up', 'Hey Brother', 'Waiting For Love'] ← a brand-new list first = 'Levels' last = 'The Nights'
Fig — Plain names claim one slot each; the single starred name scoops the leftovers into a fresh list.
💡
Why "hashable" matters
Because a tuple can never change, Python can compute a stable fingerprint — a hash — for it (as long as its contents are hashable too, like strings and ints). That fingerprint is the entry ticket to being a dictionary key: ("Levels", "Avicii") can be a key; a list never can. The next chapter is entirely about why.

One loose end is nagging: "Levels" in playlist still walks every slot — O(n), and it only gets worse as your library grows. Chapter 7 builds the structure that finds any key in one jump no matter how big it gets: the hash table behind dict and set. →

07Hash structures: dict & set

Chapter 6 gave you the list: fast to index, slow to search. This chapter gives you the structure that searches in one jump — the dictionary — and shows you the machine inside it: a hash table. By the end you'll store and fetch songs by name in constant time, dedupe a playlist with a set, and explain exactly why the dict is Python's fastest container and its hungriest.

The problem: finding one song in a million

Say your million-song library lives in a list of (title, seconds) pairs, and you want the length of "Hey Brother". A list only knows positions, not names — so Python must scan: box 0, box 1, box 2… asking each one "is it you?". The pair could be anywhere, so that's up to a million checks — and the full price again on the very next lookup. O(n), every single time.

A dictionary (dict) stores key → value pairs and fetches by key in one jump. Each key is unique; ask for a key, get its value back — no scan. You write one in curly braces — a colon glues each key to its value, commas separate the pairs, as in the code below. Since Python 3.7 a dict also remembers the order you inserted things in.

python
song = {"title": "Levels", "artist": "Avicii", "seconds": 200}

print(song["artist"])                # Avicii — one jump, no scan
print(song["seconds"])               # 200

# song["genre"]                      → KeyError: 'genre' (crash!)
print(song.get("genre", "unknown"))  # unknown — the safe fetch
list: ask every box no no no no yes! up to n checks — O(n) dict: compute, then jump "Hey Brother" 255 1 jump — O(1)
Fig — A list answers "where is it?" by looking; a dict answers by jumping straight to the slot. Same question, wildly different cost.
💡
Looping a dict gives you KEYS
for k in song: yields "title", "artist", "seconds" — keys only. Want both? for k, v in song.items():. Just values? song.values().

One jump. Not "a fast scan" — no scan at all. How can Python possibly know where a key lives without looking for it? →

hash(): every key becomes a number

Here's the trick. A hash function takes any object and crunches it into a fixed-size integer, with one iron rule: the same value always produces the same number (within one run of your program). Python's built-in is hash().

python
print(hash(200))                 # 200 — small ints hash to themselves
print(hash("Levels"))            # a huge int — different every run (see below!)
print(hash(("Levels", "Avicii")))  # tuples hash too — one number for the pair

Now the jump makes sense. A dict keeps an internal array of slots — call them buckets. To store "Levels" → 200, Python computes hash("Levels"), then takes hash % number_of_buckets — the remainder after division. A remainder when dividing by 8 can only be 0–7, so any gigantic hash collapses onto a bucket that really exists. The bucket then records where the pair lives: one reference to the key object, one to the value object — chapter 6's 8-byte addresses, not the objects themselves. "Levels" and 200 stay wherever they were built on the heap; the bucket just points at them. To read it back, Python redoes the same arithmetic, lands in the same bucket, and follows the value's reference. The key is the address, after a little math.

"Levels" hash() 2272706959 % 8 = 7 Levels 200 0 1 2 3 4 5 6 7 8 buckets — the number picks the slot (toy hash shown; real values vary per run)
Fig — Key → number → slot index. Storage and lookup run the exact same pipeline, so they land in the same bucket. (The bucket drawn holding "Levels" and 200 really holds two references; the str and the int live elsewhere on the heap.)
↺ The thing people get backwards
Most think a dict finds your key — a search, just somehow faster. It's really the opposite: the dict computes where the key must live and goes there. No comparison against other keys ever happens on the happy path. That's why lookup barely slows down whether the dict holds ten songs or ten million.

But wait — infinitely many possible keys, only 8 buckets. Two keys must eventually land on the same slot. Then what? →

Buckets and collisions

When two keys hash to the same bucket, that's a collision — and with more possible keys than buckets, collisions are guaranteed by pigeonhole logic, not bad luck. Python's fix is simple: if the home bucket is taken, probe — step to another slot until a free one turns up (wrapping past the end back to 0). Lookup later retraces the identical steps: hash, jump, "is this key equal to mine?", step, found.

Why check equality at all, if the address was computed? Because a collision may have parked a different key in your home bucket — the check confirms you found your own key, not a squatter. It's the only comparing a dict ever does, and usually it's one or two checks. So lookup is O(1) on average. The worst case — every key colliding into one long chain — degrades to O(n), but Python engineers the table to keep that a pathology, as the next section shows. Try it below: insert five songs and watch two of them fight for a bucket.

InteractiveHash five songs into 8 buckets — watch a collision probe
"Levels" toy hash = 2272706959 2272706959 % 8 = 7 0 1 2 3 4 5 6 7 bucket 7 was free — stored. One jump: O(1). toy hash: h = h×31 + character code (real Python uses a stronger, salted function)
1 / 5
Same recipe every time: hash → % 8 → jump. A collision just means "take the next free slot" — and lookup retraces the exact same steps.
"Stargazing" …260 % 8 = 4 "Hey Brother" …244 % 8 = 4 Stargazing Hey Brother 4 5 both keys' hashes end in the same slot → the newcomer steps to the next free bucket
Fig — A collision isn't an error — it's routine. The second key probes onward; the table stays correct as long as everyone retraces the same route.

Probing works while free slots are easy to find. But keep inserting songs and the table crowds up — probes get longer, O(1) starts rotting toward O(n). Python refuses to let that happen. How? →

Load factor: RAM traded for speed

The fraction of buckets in use is the load factor. Python watches it, and when a dict gets about two-thirds full, it allocates a bigger table (roughly double), then re-hashes every existing key into the new one — bigger table, new % size, new bucket for everyone. Occasionally an insert pays that O(n) rebuild, but spread across all inserts it stays O(1) on average — the same amortized deal as list append in chapter 6.

Notice what this means: a dict is deliberately kept at least one-third empty. Empty slots are the point — they keep probes short. And they're why the dict is the heaviest structure in Python: in a real profiler run, building a 1-million-entry dict jumped memory by about 101 MiB, the biggest spike of any container tested — roughly double a list holding the same data. A dict buys speed with RAM, and pays sticker price.

InteractiveFill the table — catch the moment it doubles
slots: 8 songs: 5 load: 62% ⅔ limit → resize growth so far: 8
5
The load bar climbs toward ⅔, then the table doubles and the bar halves. Every empty square is RAM spent to keep collisions rare.
8 slots, 6 used — load 75%: probes getting long past ⅔ → allocate ×2, re-hash every key 16 slots, same 6 keys — load 37%: probes short again the emptiness is the feature: sparse = short probes = O(1) — paid for in RAM
Fig — A dict keeps itself at least one-third empty on purpose. That deliberate waste is what keeps lookups at one jump.
The deeper cut
CPython's table size is always a power of two, so "hash % size" is really hash & (size−1) — one bitwise AND (chapter 0), cheaper than division. Probing isn't literally "next slot" either: the probe sequence mixes in the hash's high bits (a "perturbed" sequence) so colliding keys scatter instead of clustering. Since Python 3.6 the dict is compact: a small sparse index array points into a dense entries array that stores entries in arrival order — that redesign cut dict memory by ~20–25% and, as a side effect, made iteration follow insertion order (a language guarantee from 3.7). String hashes are salted with a per-process random seed (SipHash) so an attacker can't precompute thousands of colliding keys and stall your server — that's why hash("Levels") changes every run while hash(200) never does. One last quirk: hash() never returns −1 — that value is reserved as an internal error flag, so an object whose hash works out to −1 quietly reports −2.

The whole machine hangs on one promise: hash(key) must give the same answer today as it did at insert time. What kind of object can make that promise? →

Why a list can't be a key

Picture a mutable key. You file ["Levels", "Avicii"] under bucket 7, then rename the title inside the list. Its hash is computed from its contents — so the hash changes, and the next lookup computes a different bucket: say 3. The entry still sits in bucket 7; Python opens bucket 3, finds it empty, and reports your key missing. The data isn't gone — it's unfindable. Filed under a name that no longer exists.

Python bans the whole failure mode: dict keys must be hashable — hash fixed for the object's lifetime — and for built-in types that means immutable (chapter 3's photos, not whiteboards): str, int, float, tuple, frozenset. Lists, dicts and sets can mutate, so they're refused on the spot. Values face no such rule — only keys are addresses.

python
lengths = { ("Levels", "Avicii"): 200 }   # tuple key — frozen, fine

# { ["Levels", "Avicii"]: 200 }
# → TypeError: unhashable type: 'list'
📌
Hashable is contagious
A tuple is only hashable if everything inside it is: ("Levels", ["Avicii"]) smuggles in a list, and the whole tuple gets rejected.
at insert time ["Levels", …] hash → bucket 7 after the list mutates ["Remix", …] new hash → bucket 3 entry: 200 empty?! 3 7 the value still exists in bucket 7 — but lookup now computes bucket 3 and misses it forever
Fig — A key that can change its hash loses its own filing spot. Python bans mutable keys so this can never happen.

So a dict is really a bag of hashed keys, each dragging a value along. Strip away the values and keep just the hashed keys… and you've invented Python's other hash structure →

A set is a dict with the values ripped out

A set is the same hash table, storing keys only — no values. That buys you two things: elements are automatically unique (adding a duplicate lands in the same bucket and is dropped), and membership testing — x in s — is the same O(1) hash jump as a dict lookup. Compare that with in on a list, which scans. Sets are unordered: no indexing, no positions, just membership.

python
artists = {"Avicii", "Kygo", "Avicii"}
print(len(artists))          # 2 — the duplicate silently collapsed
print("Kygo" in artists)     # True — one hash jump, O(1)

artists.add("Alan Walker")
artists.discard("Tiësto")    # not there? no problem
# artists.remove("Tiësto")   → KeyError: 'Tiësto'

locked = frozenset(artists)  # immutable — and therefore hashable

✗ The myth

Sets are "unchangeable" — you'll see this repeated in notes and tutorials.

✓ The reality

Sets are fully mutable: add, update, discard, clear. The immutable one is frozenset — and because it's immutable it's hashable, so a frozenset can be a dict key or live inside another set.
⚠️
{} is NOT an empty set
{} creates an empty dict — dicts got the braces first. An empty set is written set(). (Non-empty braces are fine: values-with-colons make a dict, bare values make a set.)
🧠
Two lists → one dict, in a line
dict(zip(titles, seconds)) pairs them up: keys from the first list, values from the second. Chapter 5's zip rules still apply — it truncates to the shorter list, and if a title repeats, the last one wins.
python
titles  = ["Levels", "Wake Me Up", "Hey Brother"]
seconds = [200, 247, 255]

lengths = dict(zip(titles, seconds))
# {'Levels': 200, 'Wake Me Up': 247, 'Hey Brother': 255}

for title, secs in lengths.items():
    print(title, "→", secs)
dict: keys + values "Levels" 200 "Wake Me Up" 247 key column value column set: keys only "Avicii" "Kygo" "Alan Walker" "Avicii" already there — same bucket, dropped uniqueness for free: duplicates hash to an occupied bucket
Fig — Delete the value column from a dict and a set is what remains: pure O(1) membership with built-in uniqueness.

You now hold four containers: list, tuple, dict, set — each fast at something, expensive at something else. Next chapter puts them all in one ring: memory cost and Big-O side by side, plus the specialists (array, deque), and it opens with the decision map itself — five questions that pick your structure →

08The data-structure decision map

You now own four containers — list, tuple, dict, set (plus frozenset, the sealed set) — and two chapters of machinery: the dynamic array (chapter 6) and the hash table (chapter 7). This chapter turns that knowledge into reflex. After it, you'll pick the right structure for any job in about five seconds — and defend the choice with a Big-O price tag and a memory bill.

Five questions, one answer

Choosing a data structure is not a matter of taste. It's an interview — you interrogate the data, not the syntax. Five questions, asked in order, and the first "yes" decides:

1. Do you look things up by a key? Type a title, get the track → dict. 2. Does each item count at most once — do you mostly ask "is it in there?" The set of artists in your library → set. 3. Is it a fixed record, where each position means something and nothing ever changes? (title, artist, seconds)tuple. 4. Do items enter and leave at the ends? An up-next queue → deque. 5. Is it millions of plain numbers? Audio samples → array. All five "no"? Take the default: list.

That's the entire map. Everything else in this chapter is evidence for why each answer is right.

Look things up by a key? title → track yes dict key → value no Each item at most once? Fast ‘in’? the set of artists yes set unique, O(1) ‘in’ no A fixed record that never changes? (title, artist, seconds) yes tuple positions have meaning no Items enter and leave at the ends? the up-next queue yes deque O(1) at both ends no Millions of plain numbers? audio samples, track lengths yes array('i') packed bytes no list — the default ordered, mutable, grows at the end
Fig — The decision tree: the first "yes" picks your structure; five "no"s land on list, the honest default.
InteractiveFlip the requirements — watch the structure change
list tuple dict set frozenset deque No special demands: list, the default.
list
Priority order: key lookup beats everything; uniqueness beats mutability; list is what's left. (Unique + frozen has its own structure.)

Five questions land you on a structure — but "trust me" isn't an argument. What exactly does each choice cost, operation by operation? One table, every price tag →

Every price tag on one page

Quick recap of the currency. Big-O describes how work grows as the data grows: O(1) means the cost stays flat whether you hold ten songs or ten million; O(n) means the cost grows in step with the count. Chapter 6 showed why a list reads by index in O(1) but inserts in the middle at O(n) — everything after the slot shifts. Chapter 7 showed why a hash table looks up a key in O(1) on average. Here is the entire course so far, compressed into one table:

structure layout in memory s[i] x in s add at end add middle ordered? mutable? reach for it when list array of pointers O(1) O(n) O(1)* O(n) yes yes the default sequence tuple pointers, fixed size O(1) O(n) yes no a fixed record; a dict key dict hash table O(1)~ by key O(1)~ keys O(1)~ insertion yes key → value lookup set hash table O(1)~ O(1)~ no yes uniqueness, fast ‘in’ deque linked blocks O(n) O(1) at ends O(n) O(1) both ends O(n) yes yes queues, both-end work array packed C numbers O(1) O(n) O(1)* O(n) yes yes bulk numbers, tight RAM * amortized — occasional resize, cheap on average     ~ average case — O(n) worst under heavy hash collisions (ch. 7)
Fig — The master table: green cells are why you pick a structure; red cells are what you pay for it.
The cheapest speed-up in Python
if title in collection: — on a list, that scans every element: O(n). Pour the same items into a set once, and the identical line becomes O(1) average. Same code, different structure, a thousandfold gap at a million items.
↺ The thing people get backwards
Most think making Python fast means rewriting your loops with clever tricks; it's really choosing the structure that makes your hottest operation O(1). The loop body doesn't change — if x in c: reads the same either way. The cost class flips because the machinery underneath changed.
The deeper cut

The asterisks matter. Amortized O(1): a list append is usually a single pointer write, but when the reserved block fills, everything relocates — O(n) once, averaged flat over many appends (chapter 6's overallocation). Average O(1): hash lookups degrade to O(n) only if many keys collide into the same bucket — rare with real data, guaranteed order-of-growth nowhere. Since CPython 3.7, dict iteration order is guaranteed insertion order; a set makes no order promise at all. A deque is a doubly-linked chain of fixed-size blocks (64 pointers each): both ends are O(1) by construction, but dq[n//2] must walk blocks — O(n) — which is why deque is an ends-only tool. And Big-O counts element moves and probes, not nanoseconds: an array has the same O-column as a list yet wins on constants, because packed bytes stream through the CPU cache while pointer-chasing stalls it.

Wait —
the array row is identical to the list row. Same O(1) index, same O(n) middle insert, ordered, mutable. If the price tags match, why does array exist at all?

array packs, list points

Because Big-O isn't the only bill. There's also the memory bill — and there the two rows couldn't be more different.

Chapter 6 showed that a list is an array of pointers: 8 bytes per slot, each pointing at a full Python object — and chapter 2 measured a small int at 28 bytes. So one stored number costs roughly 36 bytes. The standard library's array module skips all of that: array('i', ...) declares "signed 4-byte integers, nothing else," and packs the raw values shoulder to shoulder in one block. This is chapter 0's even-stride picture — 6000, 6004, 6008 — finally made literal: in an array('i') the 4-byte values themselves sit in the consecutive boxes, the same raw layout C and NumPy use. No pointer, no object header, no refcount — 4 bytes per number, roughly nine times smaller. The price of the discount: one type code, one type. Append a string and you get a TypeError on the spot.

python
from array import array

lengths = array('i', [200, 214, 188])   # 'i' = signed int, 4 bytes each
lengths.append(251)                     # feels just like a list
lengths.remove(214)                     # by value, first match - like list
print(lengths.itemsize)                 # 4   (bytes per slot)
print(len(lengths) * lengths.itemsize)  # 12  (3 ints, packed tight)

lengths.append("Levels")                # TypeError: 'str' object cannot be interpreted as an integer
list — a row of pointers to boxed objects ptr ptr ptr ptr 8 B each int 200 28 B object int 214 28 B object int 188 28 B object int 251 28 B object per item: 8 B pointer + 28 B int object ≈ 36 B array('i') — raw values, one contiguous block 200 214 188 251 4 B each, side by side — no pointers, no boxes per item: 4 B — 9× smaller
Fig — Same four numbers, two layouts: a list points at ~36 B per item; an array packs 4 B per item.
InteractiveGrow the data — watch the two memory bills diverge
list of ints 9.00 MB array('i') 1.00 MB same numbers, same Big-O — ~9× the bytes
n = 250,000
Model: 8 B pointer + 28 B int object vs 4 B packed. Both grow linearly — the slope is the whole story.
⚠️
array is a specialist
Reach for array.array only when you have lots of numbers and a measured performance or memory problem — for everyday code, list is fine. And for serious number work, the scientific world uses NumPy: the same packing idea plus fast math on whole blocks at once. You'll see it win a race shortly.

So list has one weakness the table shows in red — the front. And there's one everyday job that hammers exactly that spot: a queue →

The queue that doesn't shuffle

Your player has an up-next queue: songs join at the back, and the next song is served from the front — first in, first out (a FIFO queue). Its mirror image is your play history: press previous and you get back the song you just heard — last in, first out, a stack. Stacks are easy: a plain list already is one, because append() and pop() both work at the end — O(1), nothing shifts.

But a queue on a list means pop(0), and chapter 6 told you the price: remove the front element and every other element is copied one slot left. O(n) — per song served. The fix is collections.deque, the double-ended queue. Instead of one contiguous block, it's a chain of small blocks, each storing the addresses of its neighbours — and the deque itself remembers just two addresses: first block, last block. Its cells hold 8-byte references, just like a list's slots. Working at an end touches only that end's block — drop a reference into the next free cell, or take one out; a fresh block is hooked on (or a spent one unhooked) only when an end block fills or empties, roughly every 64th operation. Nothing in between is ever touched. That's why append, appendleft, pop, popleft are all O(1). It also does tricks a list can't, like rotate(n), plus familiar ones like extend and count.

python
from collections import deque

up_next = deque(["Levels", "One More Time", "Strobe"])
up_next.append("Titanium")        # join at the back       O(1)
now = up_next.popleft()           # serve the front        O(1) -> 'Levels'
up_next.appendleft("Encore")      # cut in line at front   O(1)
up_next.rotate(1)                 # last track wraps around to the front
print(up_next)
# deque(['Titanium', 'Encore', 'One More Time', 'Strobe'])

✗ The myth

pop(0) is one operation. I called one method; it costs one step.

✓ The reality

One call, n moves. Every remaining element is copied one slot left. Serving a 10,000-song queue this way costs ~50 million element moves; a deque does it in 10,000.

list.pop(0) — serve the front song served Levels Strobe Titanium Alive Encore Faded then every remaining song copies one slot left — n−1 moves, O(n) deque.popleft() — serve the front song served Levels Strobe Titanium Alive Encore Faded hand over the front reference and step the cursor — 1 move, O(1); nothing else is touched
Fig — Serving one song: a list shuffles everyone forward; a deque hands over one reference at its front and steps its cursor.
InteractiveServe the whole queue — count the element moves
list.pop(0) 0 popleft() 0 total moves to drain the queue, song by song
4,000 songs
The list does ~n²/2 element moves; the deque does n. That's O(n²) vs O(n) — the gap that makes apps freeze.

Time costs are now mapped edge to edge. But twice this chapter we've muttered "heavier" and "smaller" about memory. Enough muttering — let's put a million items on the scale →

The bill for a million items

Here's a real measurement: build one million items four different ways on a real machine, and watch the process's memory with a profiler. The ranking:

dict 1M key:value pairs 205.6 MiB list 1M ints, boxed 96.5 MiB NumPy array 1M numbers, packed +7.6 MiB generator produces on demand ≈ 0 — lazy (ch. 10)
Fig — One real profiler run, n = 1,000,000. Your exact numbers will differ; the ranking won't.

The ranking follows straight from layout. A dict is the heaviest because it stores a hash table — buckets, spare slots for the load factor, hashes — plus every key object plus every value object. A list pays a pointer per item plus each boxed object. A NumPy array packs raw numbers like array('i') does, with fast math on top. And a generator stores almost nothing — it keeps only the recipe and cooks one value at a time. That trick gets a whole chapter soon.

💡
The structure that stores nothing
The green sliver isn't an error. A generator holding "the numbers 0 to 999,999" occupies a couple hundred bytes, because it never holds the numbers — only where it left off. Chapter 10 builds one.

And the whole decision map, applied to the thing we've been building all along:

python
from collections import deque
from array import array

track    = ("Levels", "Avicii", 200)        # tuple - a record, never changes
playlist = [track]                          # list  - ordered, grows at the end
by_title = {"Levels": track}                # dict  - O(1) avg lookup by key
artists  = {"Avicii", "deadmau5"}           # set   - unique, O(1) 'in'
up_next  = deque(["Strobe", "Levels"])      # deque - O(1) at both ends
lengths  = array('i', [200, 337, 214])      # array - packed 4-byte ints

Every structure on this map just sits there until code acts on it. Next: functions — machines you build once and feed the playlist through, with inputs, a return value, and a strange rule about where their variables live →

09Functions

You have data structures; now you need machines that work on them. After this chapter you can bundle steps into a named, reusable unit, control exactly how values flow in and out of it, watch the call stack grow and shrink as it runs, and — the strange part — hand one machine to another machine, because in Python a function is just another object.

A machine with a name

Printing one song takes three lines. Printing forty songs takes a hundred and twenty — unless you build the three lines once, name them, and reuse the name. That is all def is: bundle steps, hang a name on the bundle.

python
def describe(title, artist, seconds):
    line = title + " by " + artist
    print(line, "-", seconds, "s")

describe("Levels", "Avicii", 200)   # Levels by Avicii - 200 s

Two words, defined now and used forever: the names in the def line — title, artist, seconds — are parameters: the labelled empty slots built into the machine. The actual values you drop into those slots at the call — "Levels", "Avicii", 200 — are arguments. Mnemonic: the parameter is part of the machine's design; the argument is what arrives when you press GO. And in memory? def builds a function object on the heap — its compiled body plus its parameter names — and binds the name describe to it, exactly like a = 10 bound a name to an int in chapter 3. The body does not run.

📌
def is a statement, not a rehearsal
Python executes the def line when it reaches it — but executing it only builds the object and binds the name. The body waits — compiled and shelved, but never yet run — until parentheses press GO.
def describe(title, …): line = title + … print(line, …) runs ONCE function object (heap) body, compiled — NOT run yet params: title, artist, seconds describe describe("Levels", "Avicii", 200) ← the parentheses are what press GO
Fig — def builds a purple machine and hangs a blue name on it; nothing runs until parentheses.

Three arguments in strict order worked. But what happens when the call and the def disagree about order — or about count? →

How arguments find their parameters

Python matches arguments to parameters three ways. Positional: by order — first argument fills the first slot. Keyword: by name — seconds=200 flies straight to the seconds slot, order be damned. Default: a fallback written in the def line, used only when a slot stays empty. One traffic rule: positionals first, keywords after — once you go keyword, you can't go back.

python
def add_song(title, artist="Unknown", seconds=180):
    print(title, "by", artist, "-", seconds, "s")

add_song("Levels", "Avicii", 200)                # by position
add_song("Levels", seconds=200, artist="Avicii") # by name
add_song("Fade")            # Fade by Unknown - 180 s
add_song("Levels", seconds=200, artist="Avicii") "Levels" seconds=200 artist="Avicii" position 1 → slot 1 matched by NAME title "Levels" artist "Avicii" seconds 200 a default fills any slot the call leaves empty
Fig — positionals fill slots left-to-right; keywords fly straight to the slot carrying their name.

Two more collectors handle the overflow. *args scoops every spare positional into a tuple; **kwargs scoops every spare keyword into a dict — the amber structures from chapters 6 and 7, built for you at call time. (args and kwargs are conventions; the stars do the work.)

python
def playlist_length(*songs):        # songs is a TUPLE
    total = 0                       # a running total, starting empty
    for s in songs:                 # walk the tuple Python just packed
        total += s                  # add this song's seconds
    return total                    # send the total back — full story next section

playlist_length(200, 431, 354)      # 985

def tag_song(title, **extras):      # extras is a DICT
    for key, value in extras.items():
        print(title, key, value)

tag_song("Levels", artist="Avicii", year=2011)
playlist_length(200, 431, 354) 200 431 354 songs = (200, 431, 354) *args → a tuple (chapter 6) tag_song("Levels", …) artist="Avicii" year=2011 extras = {'artist': 'Avicii', 'year': 2011} **kwargs → a dict (chapter 7)
Fig — spare positionals land in a tuple, spare keywords in a dict — nothing is dropped.

✗ The myth

Arguments are copied into the function ("pass by value"), so nothing inside can ever leak out.

✓ The reality

No copy is made. The parameter becomes a new name for the same object — chapter 3's aliasing, at every call. Rebinding the parameter (s = s + 1) is invisible outside; mutating a passed list (songs.append(...)) is fully visible. Pass-by-object-reference.
The deeper cut

The full signature order is: positionals → *args → keyword-only → **kwargs. Two extra markers exist: names before a bare / are positional-only, names after a bare * are keyword-only:

python
def add_song(title, /, artist, *, seconds=180):
    ...
add_song("Levels", "Avicii", seconds=200)   # ok
add_song(title="Levels", artist="Avicii")   # TypeError: title is positional-only

And a mechanism that bites: default values are evaluated once, at def time, and stored on the function object itself — not rebuilt per call. Defaults are shared state in disguise.

⚠️
The mutable default trap
def add(song, playlist=[]) builds one list at def time; every call without an argument appends to that same list — add("Levels") then add("Fade") returns ['Levels', 'Fade']. The idiom: default to None, then if playlist is None: playlist = [] inside the body.

So arguments flow in. What flows back out — and what does Python hand you if the function never says? →

What comes back: return, or None

return does two things at once: it ends the call immediately, and it hands exactly one object back to the call site. The call expression becomes that object — total = playlist_length(200, 431, 354) makes total a name for 985. And if a function has no return? Python still hands something back: None, the built-in nothing-object. You can prove it:

python
def shout(title):
    print(title.upper())        # prints, but returns nothing

result = shout("Levels")        # LEVELS appears here
print(result)                   # None

Want two values back? return title, seconds quietly packs them into one tuple — remember, the comma makes the tuple — and chapter 6's unpacking splits it at the other end: title, seconds = longest_song(). Still exactly one object crossing the bridge.

playlist_length(…) return total 985 total = 985 shout("Levels") no return line None result = None
Fig — every call evaluates to exactly one object; silence means None, not nothing.
Wait —
inside shout, a local name title existed for a few microseconds and then vanished. Where exactly did it live?

The call stack: frames that push and pop

Every call gets a frame: a scratch pad in a special region of memory — the stack — holding that call's local names. Call a function: a frame is pushed on top. Hit return: the frame is popped and its names are destroyed, the return value flowing down to the frame below. Last in, first out — the same LIFO discipline as chapter 8's stack structure, because it is one, built into the interpreter. That's why two calls to the same function can't see each other's locals: each got its own pad. The objects the names point to live elsewhere, on the heap — chapter 11 draws that border precisely. Drag through a real run:

InteractiveScrub time — watch frames push and pop
THE CALL STACK — grows upward ↑ def song_seconds(m, s): return m*60 + s def total_seconds(): a = song_seconds(3, 20) b = song_seconds(4, 5) return a + b total = total_seconds()
step 0 / 6
Calls push, returns pop — last frame in is first frame out (LIFO).
before the call module total = ? two calls deep module total = ? total_seconds a = 200 b = ? song_seconds ← runs m = 4 s = 5 after both return module total = 445 calls PUSH returns POP
Fig — the top frame is the one running; everything below it is paused, waiting for a value to flow down.
Wait —
if every call pushes a fresh frame, what happens when a function calls itself? The stack just grows — frame on frame on frame. That's recursion, and we trace it step by step in Part 2. Grow it too deep and Python stops you with RecursionError.

Frames give every call private names. So when the same word names two different things — one inside, one outside — who wins? →

LEGB: where Python looks up a name

A name's scope is the region of code where it can be found. When a line uses a name, Python searches four places, in order, and stops at the first hit: Local (this frame), Enclosing (the frame of the def this def sits inside, if any), Global (the module), Built-in (len, print, range — names Python ships). Four misses and you get NameError. One sharp edge: assigning to a name anywhere in a function makes it local for the whole function —

python
plays = 0                      # a global name

def play_song(title):
    plays = plays + 1          # UnboundLocalError!
    print("now playing", title)

Why does the very line that assigns plays fail? Because Python sorts names into local or global when it compiles the def, not while running it: it scans the whole body first, sees plays = …, and stamps plays as local before a single line executes. At run time, the read half of plays = plays + 1 then finds a local slot that holds nothing yet — hence the error. To really rebind the global, opt in with global plays inside the function; to rebind an enclosing function's name, the keyword is nonlocal — you'll see it earn its keep two sections down.

🔥
global is a loaded weapon
A function that rewrites globals has invisible side effects — every caller now shares hidden state, and testing it means rebuilding the world around it. Prefer return the new value; let the caller decide where it goes.
GLOBAL (module) plays 0 never reached play_song frame (LOCAL) plays 1 print(plays) → sees 1 L wins first
Fig — same word, two names, two rooms: inside the function, the local plays eclipses the global one.
InteractiveLook up a name — watch the LEGB search
B — Built-in: len, print, range … G — Global (module): playlist = […] E — Enclosing (outer def): rate = 1.1 L — Local (this frame): s = 200 s
s
Python searches L → E → G → B and stops at the FIRST hit; four misses = NameError.

One more consequence of "def just builds an object and binds a name": anything you can do to an object, you can do to a function →

Functions are values: pass the machine, not its output

A function is an object, so it can sit in a list, be assigned a second name, or be handed to another function as an argument. Python calls this first-class. And it finally explains chapter 6's mysterious sort rule:

python
playlist = [("Hey Jude", 431), ("Levels", 200), ("Take Five", 324)]

def by_seconds(song):
    return song[1]                 # the seconds field

playlist.sort(key=by_seconds)      # pass the machine — NO parentheses
# [('Levels', 200), ('Take Five', 324), ('Hey Jude', 431)]
↺ The thing people get backwards
Most think the parentheses in by_seconds() "belong to the function name". It's really two separate things: by_seconds is a name for the function object; () is the call operator that runs it. key=by_seconds hands sort the machine to run once per song; key=by_seconds() would run it now — wrongly, with no argument — and hand sort a single answer instead of a machine.

When the machine is one expression and needs no name, build it inline with lambda: playlist.sort(key=lambda song: song[1]). Read it left to right: lambda announces a tiny function, song is its one parameter, and whatever the expression after the colon evaluates to is automatically returned — no def, no return, no name. Same kind of function object, born anonymous at the call site.

by_seconds function object song → song[1] key=by_seconds ✓ sort receives the machine, runs it once per song key=by_seconds(…) 431 ✗ runs NOW — sort gets one number, not a machine
Fig — no parentheses passes the machine itself; parentheses would run it once and pass the answer.

Last trick: define a function inside a function and return it. The inner function keeps using names from its enclosing frame — even after that frame pops. Python rescues each needed variable into a small heap-allocated cell the inner function grips. That inner function is a closure, and nonlocal is how it rebinds the rescued name:

python
def make_counter():
    count = 0                  # lives in make_counter's frame
    def bump():
        nonlocal count         # rebind the ENCLOSING name
        count += 1
        return count
    return bump                # hand the inner machine out

plays = make_counter()
print(plays(), plays(), plays())   # 1 2 3
make_counter frame POPPED — gone rescued to the heap cell: count = 3 bump — function object still holds the cell closure grip plays plays() → 4
Fig — the frame died; the variable the inner function needs lives on in a cell — private state, no class required.
💡
A closure is a function with a backpack
It carries the enclosing variables it needs wherever it goes. Each make_counter() call packs a fresh backpack — two counters never share a count. It's the memory-lightest way to give a function state; when the state gets rich, you'll graduate to a class (chapter 12).

You can now hand a machine to sort. Chapter 10 hands machines to map and filter, shrinks whole loops into one-line comprehensions — and builds sequences so lazy they occupy almost no memory at all →

10Functional tools & lazy iteration

Chapter 9 turned functions into values you can hand around. This chapter cashes that in: by the end you can transform, filter, and total an entire playlist in single lines, sort it by any rule you invent, and build a million-item sequence that costs a few hundred bytes — because it doesn't exist until the moment you ask for it.

map and filter: two machines on a conveyor

You could loop over the playlist and build results by hand. Python ships two machines that do it for you. map(fn, iterable) takes a function reference — no parentheses, chapter 9's rule — and runs every item through it, giving you the transformed stream. filter(pred, iterable) takes a predicate, a function that answers True or False for one item, and keeps only the items that answer True. Everything else falls off the belt.

One catch: both return lazy objects. Print a map object and you get <map object at 0x104...> — not your data. Nothing has been computed yet; it's a promise, not a result. Wrap it in list() or loop over it, and it pays up one item at a time. A bonus: map accepts several iterables at once — map(min, a, b) takes one item from a and one from b per step and hands the pair to min, so out comes the smaller of each pair.

python
playlist = [
    {"title": "Levels",        "artist": "Avicii",    "seconds": 200},
    {"title": "One More Time", "artist": "Daft Punk", "seconds": 320},
    {"title": "Strobe",        "artist": "deadmau5",  "seconds": 634},
    {"title": "Wake Me Up",    "artist": "Avicii",    "seconds": 247},
]

upper_titles = map(lambda s: s["title"].upper(), playlist)
print(list(upper_titles))
# ['LEVELS', 'ONE MORE TIME', 'STROBE', 'WAKE ME UP']

long_songs = filter(lambda s: s["seconds"] > 300, playlist)
for song in long_songs:
    print(song["title"])
# One More Time
# Strobe
map(fn, songs) — transform every item 'Levels' 'Strobe' .upper() 'LEVELS' 'STROBE' filter(pred, secs) — keep items where pred is True 200 320 634 247 sec > 300 ? 320 634 200 247 dropped ✗
Fig — map runs every item through a transforming machine; filter is a gate — items answering False fall off the belt.
💡
A map object is an IOU
print(upper_titles) shows <map object at 0x104...> because no work has happened yet. The work happens when something consumes it — list(), a for-loop, sum(). Hold that thought: this laziness becomes the whole point later in this chapter.

map turned four songs into four titles; filter kept two survivors. But the playlist's total runtime is ONE number built from all four. Which machine folds many values into one? →

reduce: fold many values into one

map is many-to-many. filter is many-to-fewer. reduce is many-to-one: it folds a whole sequence down to a single value. It isn't built in — you import it with from functools import reduce.

It works by carrying an accumulator: a running result. Your function takes two parameters — the accumulator always first, the next item second — and returns the new accumulator. reduce starts from an initial value (here 0), then feeds items through one at a time: 0+200→200, 200+320→520, 520+634→1154, 1154+247→1401. The last accumulator is the answer.

python
from functools import reduce

total = reduce(lambda acc, song: acc + song["seconds"], playlist, 0)
print(total)          # 1401
# fold: 0+200 → 200 | +320 → 520 | +634 → 1154 | +247 → 1401
reduce(lambda acc, s: acc + s["seconds"], playlist, 0) +200 +320 +634 +247 0 200 520 1154 1401 initial value total runtime ✓
Fig — reduce carries an accumulator through the sequence: it starts at 0, swallows one value per step, and ends at 1401.
InteractiveFold the playlist — one song at a time
Levels200 s One More Time320 s Strobe634 s Wake Me Up247 s acc = 0 (the initial value) 0 s accumulator — total seconds folded in so far
0 / 4 songs folded
The accumulator is a snowball: it rolls over each song once and keeps everything it picks up.
Wait —
the sum fold starts at 0. If you folded with acc * item to get a product instead, what must the initial value be — and what would starting at 0 do to every answer?
The deeper cut
reduce used to be a built-in. Python 3 demoted it to functools on readability grounds: most folds already have names. sum(...), max(...), min(...), math.prod(...), any(...), all(...) are all reduces in disguise, and each says what it computes instead of how. Reach for raw reduce only when the fold has no name — merging a sequence of dicts, threading a value through a pipeline of functions. If you find yourself writing reduce(lambda x, y: x + y, ...), the answer was sum.

reduce, map, filter, lambda, list() — five moving parts for a one-line job. Python looked at that pile and decided it was four parts too many →

Comprehensions: map + filter in one readable line

A list comprehension is Python's native spelling for "transform some items, keep some items": [expr for x in xs if cond]. It reads in three slots. The expression up front says what each kept item becomes. The for clause says which items to visit. The optional if at the end is the gate — the filter.

Two placement rules that trip everyone: a filtering condition goes after the for ([x for x in xs if cond]); a transforming ternary — the one-line a if cond else b expression — goes before it ([a if cond else b for x in xs]). The filter decides which items get in; the ternary decides what shape each kept item takes. And the wrapper picks the container: swap the square brackets for curly braces and you get dict and set comprehensions for free — a key: value expression inside makes a dict, a lone expression makes a set.

python
long_titles = [s["title"] for s in playlist if s["seconds"] > 300]
# ['One More Time', 'Strobe']            ← filter: if AFTER for

tags = ["long" if s["seconds"] > 300 else "short" for s in playlist]
# ['short', 'long', 'long', 'short']     ← transform: ternary BEFORE for

durations = {s["title"]: s["seconds"] for s in playlist}   # dict comp
artists   = {s["artist"] for s in playlist}                # set comp
print(artists)   # 3 unique artists from 4 songs (set order varies)
s["title"] the result — what each kept item becomes for s in playlist the loop — visit every song if s["seconds"] > 300 the gate — keep only if True [s["title"] for s in playlist if s["seconds"] > 300]
Fig — a comprehension is three labelled slots inside brackets — result, loop, gate. It replaces map and filter in one line you can read aloud.

One thing to notice before moving on: a comprehension is eager. The moment the line runs, the full list exists in memory — every element, all at once.

Wait —
square brackets build a list. Curly braces build a dict or set. So what happens if you write the same comprehension inside round brackets?

A comprehension of a million items builds a million items before you touch the first one. What if you only ever need one at a time? →

Generators: sequences that don't exist yet

A generator is a function that uses yield instead of return. Calling it runs none of its body. Instead you get back a generator object: the function's code plus a bookmark. Each time something asks for the next value — next(), a for-loop, sum() — the body runs until the next yield, hands over one value, and pauses right there, local variables intact. That's lazy evaluation: compute on demand, one at a time.

And the round brackets from the last section? (expr for x in xs) is a generator expression — a comprehension that builds nothing. The memory difference is brutal. A list of a million squares materialises a million boxed int objects plus a million-slot pointer array — roughly 40 MB of RAM, all paid before you touch the first square. The generator version is one small object — around 200 bytes — no matter how many values it will eventually produce, because it stores the recipe, not the meal.

python
def song_lengths(songs):
    for song in songs:
        yield song["seconds"]

gen = song_lengths(playlist)
print(next(gen))   # 200 — ran until the first yield, then paused
print(next(gen))   # 320 — resumed exactly where it left off

big_list = [x * x for x in range(1_000_000)]   # ≈ 40 MB, built NOW
big_gen  = (x * x for x in range(1_000_000))   # ≈ 200 bytes, builds nothing
print(sum(big_gen))   # 333332833333500000 — one square at a time
[x*x for x in range(1_000_000)] eager — every element built now … × 1,000,000 a million boxed ints + a million pointers ≈ 40 MB (x*x for x in range(1_000_000)) lazy — a recipe, not the meal code + a bookmark paused at yield 4 next(g) → 0, 1, 4, 9, … one value at a time its size never grows with n ≈ 200 B
Fig — same million squares: the list materialises every int now (≈40 MB); the generator stores a paused recipe (≈200 B) and cooks one value per request.
InteractiveSlide n to a million — watch who pays
list [ ] ≈ 3.8 MB gen ( ) ≈ 200 B the list costs ≈ 20,000× more than the generator estimate: ~40 B per list item (8-B pointer + boxed int object); the generator stores only code + one bookmark
100,000 items
The list's cost grows in a straight line with n; the generator's never moves. That flat green sliver is laziness.
↺ The thing people get backwards
Most think a generator is a compressed list — the same data, squeezed smaller. It's really a paused function: the data doesn't exist at all yet. Nothing was shrunk, because nothing was built. You're holding a recipe with a bookmark in it, and every next() cooks exactly one serving.
⚠️
Generators are single-use
Once consumed, a generator is empty forever: lengths = (s["seconds"] for s in playlist), then sum(lengths) gives 1401 — but a second sum(lengths) gives 0, because the bookmark is at the end. The same applies to map, filter, and zip objects. Need the data twice? Store it in a list once, or rebuild the generator.
The one-liner that retires reduce
sum(s["seconds"] for s in playlist)1401. A generator expression passed straight into a function needs no extra parentheses. Total runtime, no lambda, no import, no intermediate list in memory.
The deeper cut
A for-loop actually calls iter() on your object, then calls next() repeatedly until the iterator raises StopIteration — that exception, caught silently by the loop, is how iteration ends. Generators are iterators, which is exactly why they're one-shot. But range(1_000_000) is lazy without being an iterator: it's a lazy sequence, so you can loop over it twice and index into it. And this machinery is why os.walk() — a generator — can crawl an entire drive without loading the file tree into RAM: it hands you one directory at a time and never builds the whole tree in memory.

Lazy or eager, a playlist eventually wants to be in ORDER — and Python ships two sorts that behave in opposite ways →

sorted() vs .sort(): the method/function rule

Here's the rule, and it's worth memorising because it extends far beyond sorting: a dot-method modifies the object itself and returns None; a wrapping function returns a new modified value and leaves the original alone. playlist.sort() reorders the actual list in place — cheap, no copy, but the old order is gone. sorted(playlist) allocates a fresh sorted list and hands it back; the original never moves. And sorted() accepts any iterable — a tuple, a set, even a generator — while .sort() exists only on lists.

Both take key=: a function reference, no parentheses. Each item is judged by whatever the key function returns — a proxy value — rather than by the item itself. That's chapter 9's first-class functions earning their keep.

python
by_length = sorted(playlist, key=lambda s: s["seconds"], reverse=True)
print(by_length[0]["title"])   # Strobe — longest first
print(playlist[0]["title"])    # Levels — original untouched

result = playlist.sort(key=lambda s: s["seconds"], reverse=True)
print(result)                  # None — .sort() returns nothing
print(playlist[0]["title"])    # Strobe — the list ITSELF was reordered
playlist.sort(key=...) dot-method → mutates the object itself playlist reordered IN PLACE returns: None sorted(playlist, key=...) function → returns a NEW list playlist untouched ✓ new list sorted copy returns: the new list
Fig — the dot-method reorders the object and returns None; the function leaves the original alone and returns a fresh sorted list.

✗ The myth

.sort() gives you back the sorted list, so songs = songs.sort() is a tidy one-liner.

✓ The reality

.sort() mutates in place and returns None — that "tidy one-liner" replaces your list with None and your data is one name-binding away from gone. Write songs.sort() alone, or songs = sorted(songs).

📌
Trade-off, not a winner
.sort() needs no extra memory — it rearranges the existing list. sorted() allocates a whole new list, O(n) extra space, but keeps the original safe. Big data and no need for the old order? .sort(). Anything else? sorted() — the version that can't surprise you.

You just watched a million-item list cost forty megabytes while its lazy twin cost two hundred bytes. Next: the chapter that opens the hood on all of it — weighing any object with getsizeof, slimming classes with __slots__, and caches that deliberately trade memory for speed →

11Memory, mastered

Every chapter so far has whispered the same question: how many bytes is this, really? This chapter answers it out loud — and then cuts the bill. By the end you'll weigh any object with one function call, pack a million track lengths nine times smaller, strip hundreds of megabytes off a music library, build caches that can't leak, and treat a 70 MB file on disk as if it were an in-RAM array.

First, weigh it

Chapter 3, in one line: every object carries a reference count — the instant it hits zero the object is freed — and a generational garbage collector sweeps up the reference cycles the counter can't see. That machinery decides when memory comes back. This chapter is about how much you hand over in the first place.

The scale is sys.getsizeof(obj): the bytes an object occupies. One int weighs 28 bytes (chapter 2). A list of ten track lengths weighs 136 bytes. But read that second number carefully. 136 = 56 bytes of list header + 10 pointers × 8 bytes. That's the container only. The ten int objects those pointers lead to are ten more parcels of 28 bytes each — 280 bytes living elsewhere on the heap, uncounted.

python
import sys

# ten track lengths, in milliseconds (like real music APIs use)
lengths_ms = [200000, 355000, 238000, 194000, 260000,
              215000, 222000, 301000, 244000, 313000]

print(sys.getsizeof(42))          # 28  — one boxed int
print(sys.getsizeof(lengths_ms))  # 136 — the list CONTAINER only
lengths_ms — a list of 10 track lengths header 56 B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B ptr8B sys.getsizeof(lengths_ms) → 136 B (56 + 10 × 8) int 200000 · 28 B int 355000 · 28 B …8 more · 8 × 28 B these 280 bytes are real — getsizeof just doesn't count them
Fig — getsizeof weighs the container (136 B), never the 280 B of int objects it points to.
↺ The thing people get backwards
Most think getsizeof tells you how much memory your data uses; it really measures one object's own bytes — the container, not the contents. A tutorial that says a 136-byte list "stores ten integers" is overstating: the list stores ten pointers. Truer totals need a recursive walk that follows every pointer (with an id()-based guard so shared and cyclic objects aren't counted twice).

So ten numbers really cost 136 + 280 = 416 bytes — for 40 bytes of actual numeric data. Can we pay 40 and skip the rest? →

Pack the numbers: array vs list

A list is flexible because every slot is a pointer — chapter 6. Each track length costs an 8-byte pointer plus a 28-byte boxed int: 36 bytes per number. The array module makes the opposite deal: if every element is the same type, drop the boxes. array('i', ...) stores raw machine integers — the bare 4-byte numbers the processor works with directly, no Python box around them — packed shoulder to shoulder at itemsize = 4 bytes each.

python
from array import array
import sys

lengths = array('i', [200000, 355000, 238000, 194000, 260000,
                      215000, 222000, 301000, 244000, 313000])

print(lengths.itemsize)        # 4   — bytes per element, fixed
print(sys.getsizeof(lengths))  # 120 — header 80 B + 10 × 4 B, ALL of it
print(lengths[2])              # 238000 — still indexes like a list

No pointers means getsizeof is now the whole truth: 120 bytes, everything included. The price is flexibility — an array('i') holds ints only; append a string and Python raises TypeError.

list — each number is a pointer to a boxed object ptr 8 B ptr 8 B ptr 8 B ptr 8 B 200000 int object · 28 B 355000 int object · 28 B 238000 int object · 28 B 194000 int object · 28 B cost per number: 8 B pointer + 28 B object = 36 B array('i') — the same numbers as raw 4-byte cells 200000 355000 238000 194000 260000 215000 cost per number: 4 B — no pointer, no box, no header
Fig — Same numbers, two layouts: 36 B each boxed-and-pointed vs 4 B each packed.

✗ The myth

Python is just memory-hungry — if data gets big, your only options are a bigger machine or less data.

✓ The reality

Python's defaults are flexible-but-heavy. Every tool in this chapter trades unused flexibility for bytes: array packs, __slots__ trims, generators stream (chapter 10), memmap pages from disk.
InteractiveSlide n — watch the list bill grow 9× faster than the array bill
n = 1,000 track lengths list of int objects 36.1 KB array('i') 4.1 KB the array is 8.8× smaller
1,000
list = 56 + 36·n bytes (8 B pointer + 28 B boxed int per number); array('i') = 80 + 4·n. The gap climbs toward 9×.
🧠
This is NumPy's whole secret
Chapter 8's memory ranking — dict > list > NumPy > generator — exists because a NumPy array is this same raw-cells trick, industrialized: typed values, packed contiguously, zero boxes.

Arrays pack numbers. But a Song isn't a number — it has a title, an artist, a length. How do you shrink an object with named fields? →

Shrink every object: __slots__

Here's a preview of chapter 12: a class is a blueprint for objects with named attributes. By default, every instance built from it carries a personal __dict__ — a full hash table (chapter 7) mapping attribute names to values — so you can bolt new attributes onto any object at any time. Flexibility again, and again it's heavy: on CPython 3.12 (64-bit), one plain Song is a 48-byte object plus a 296-byte dict. 344 bytes of skeleton per song, before the title string even starts.

__slots__ is the trade: declare the complete attribute list up front, and Python deletes the dict, storing each attribute in a fixed slot inside the object itself.

python
import sys

class PlainSong:                    # classes proper: chapter 12
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

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

p = PlainSong("Levels", "Avicii", 200)
s = Song("Levels", "Avicii", 200)
print(sys.getsizeof(p) + sys.getsizeof(p.__dict__))  # 344  (48 + 296)
print(sys.getsizeof(s))                              # 56 — no __dict__ at all
PlainSong — every instance drags a dict instance · 48 B __dict__ · 296 B 'title' → ptr → 'Levels' 'artist' → ptr → 'Avicii' 'seconds' → ptr → 200 48 + 296 = 344 B per song Song with __slots__ — attributes live inside Song instance · 56 B slot 0: title → ptr slot 1: artist → ptr slot 2: seconds → ptr 56 B per song — the dict is gone
Fig — __slots__ deletes the per-instance __dict__: 344 B of skeleton becomes 56 B.
InteractiveToggle __slots__ — watch a million-song library shed 288 MB
one Song instance Song · 48 B __dict__ · 296 B one per instance = 344 B per song × 500,000 songs 172.0 MB tick “declare __slots__” to shrink every instance
500,000
Plain: 344 B of skeleton per song. Slotted: 56 B. The dict was the heavy part — and __slots__ deletes it from every instance.
⚠️
__slots__ is a contract
You gave up the dict, so you gave up its superpower: s.year = 2011 now raises AttributeError — no attribute outside the declared list, ever. And a slotted instance can't be weakly referenced (next section) unless you add '__weakref__' to the slots.
The deeper cut
Modern CPython already softens the plain-class cost: since PEP 412, instances of one class share a single key table (each dict stores only its values), and since 3.11 the __dict__ isn't even created until something asks for it. The measured 296 B is the price once it materializes. __slots__ still wins — the saving is unconditional, guaranteed, and attribute access gets slightly faster because a slot is a fixed offset rather than a hash lookup. Under the hood each slot is a descriptor on the class pointing at a fixed byte offset in the instance — machinery you'll recognize after chapter 12.

Smaller objects still cost bytes forever if something refuses to let them die. And the usual suspect is the very thing built to help: a cache. What if a reference could just… let go? →

The reference that lets go: weakref

Chapter 3's rule: an object lives while its reference count is above zero. So any long-lived dict that stores objects — an album-art cache, say — keeps every one of them alive, even after the rest of the program has moved on. The playlist drops a song; the cache still grips it; the bytes never come back. That's a memory leak with good intentions.

A weak reference points at an object without counting. The object's life is decided by strong references only; the weak one just watches. weakref.WeakValueDictionary is a dict whose values are held weakly — when the last strong reference dies, the entry evaporates on its own.

python
import weakref

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

art_cache = weakref.WeakValueDictionary()
levels = Song("Levels")
art_cache["Levels"] = levels     # weak — adds 0 to the refcount

print(len(art_cache))   # 1
del levels               # last STRONG reference gone → freed instantly
print(len(art_cache))   # 0 — the entry removed itself
before: two arrows in, only one counts levels strong — counts Song('Levels') the object 1 refcount art_cache entry weak — adds 0 after: del levels levels name deleted Song('Levels') refcount 0 → freed instantly 0 art_cache: empty entry removed itself — no leak
Fig — A weak reference watches without counting: when the strong references go, the object goes — and the cache cleans itself.
Wait —
if the weak reference never kept the Song alive, what do you get when you ask for it after the Song dies?

A raw weakref.ref(song) is a callable: it hands back the object while it lives and None once it's gone. The dictionary version simply acts as if the key was never there.

📌
Not everything can be watched
Only some objects accept weak references: instances of your own classes, functions, sets. Plain int, str, and tuple refuse — they have no slot for the watcher (and remember: slotted classes need '__weakref__' declared).

weakref stops accidental hoarding. But sometimes hoarding is exactly the plan: spend memory on purpose, and buy back time →

Spend bytes, buy speed: lru_cache

Some functions recompute the same answer thousands of times. Formatting 200000 ms as "3:20" is cheap, but imagine it's a database lookup or a network call — same input, same output, paid for on every call. Memoization is the fix: remember past results, keyed by the arguments. functools.lru_cache bolts it onto any function with one line. That @lru_cache(...) line is a decorator: it takes your function — an object you can hand around, chapter 9's whole point — and rebinds the name as_clock to a wrapped version that checks a hidden dict of past answers before ever calling the original.

python
from functools import lru_cache

@lru_cache(maxsize=128)          # keep the 128 most recent results
def as_clock(ms):
    seconds = ms // 1000                          # 200000 ms → 200 s
    return f"{seconds // 60}:{seconds % 60:02d}"  # minutes:padded seconds — 185 s gives "3:05", not "3:5"

print(as_clock(200000))      # 3:20 — computed, then stored
print(as_clock(200000))      # 3:20 — served from the cache
print(as_clock.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)

This is the opposite trade from everything above: spend memory to save time. LRU means least recently used — when the cache is full, the entry untouched the longest is evicted first, so the memory bill stays capped at maxsize results.

as_clock(200000) seen these args before? no — miss compute 200000 → '3:20' store in cache up to 128 entries yes — hit return '3:20' a hit costs no computation at all
Fig — lru_cache: miss = compute + store; hit = instant answer. Memory traded for speed, capped at maxsize.
💡
Two rules of the deal
Arguments must be hashable — they become dict keys inside the cache, so as_clock([200000]) with a list raises TypeError. And maxsize=None means unbounded: an ever-growing cache is exactly the leak the previous section warned about. Cache pure functions (same args → same answer, always) — a result that depends on the current time will be served stale.

Packing, trimming, evicting — all games played inside RAM. What about the day the data simply doesn't fit in RAM at all? →

Bigger than RAM, cleaned up on time

One song's raw waveform is 8,820,000 samples (200 s × 44,100 per second). As float64 — 8 bytes each — that's 70.6 MB per track; a playlist of them would drown your RAM. numpy.memmap dodges the problem: it presents a file on disk as if it were an in-memory array. You index it normally, and the operating system copies into RAM only the small 4 KB chunks — pages — that you actually touch; the rest of the file never leaves the disk.

python
import numpy as np    # pip install numpy

wave = np.memmap("levels.dat", dtype="float64",
                 mode="w+", shape=(8_820_000,))   # 70.6 MB file, ~0 RAM
wave[0:3] = [0.0, 0.12, 0.25]   # write through the "array"
wave.flush()                    # push pending changes to the file
del wave                        # close the mapping when done
RAM 4 KB page 4 KB page the slice you touched levels.dat — 70.6 MB on disk 8,820,000 float64 samples index it like an array — the OS loads only the pages you actually read or write
Fig — memmap: the array lives on disk; RAM holds only the pages you touch.

But notice the two chores: flush(), then close. Files, mappings, network sockets — these are resources borrowed from the operating system, outside the garbage collector's easy reach. If your code crashes between open and close, the cleanup line never runs. Python's answer is the context manager: any object with an __enter__ method (runs at the top of a with block) and an __exit__ method (runs when the block ends — no matter how it ends).

python
with open("playlist.txt", "w") as f:      # __enter__ → f is the open file
    f.write("Levels — Avicii — 200\n")
    f.write("Wake Me Up — Avicii — 247\n")
# leaving the block ran __exit__ → f is GUARANTEED closed,
# even if a write() in the middle had raised an error
with open('playlist.txt') as f: __enter__ → file handle acquired your indented block runs …even if a line in it raises normal exit exception __exit__ → file closed. Always. the exception still travels onward afterwards — unless __exit__ returns True
Fig — with guarantees the exit: acquire in __enter__, release in __exit__, on every path out of the block.
🧨
The bug this kills
An unflushed memmap or an unclosed file means buffered data silently never reaches the disk — the program "worked" and the file is wrong. Never rely on a cleanup line placed after risky code; put the resource in a with and the cleanup can't be skipped.
The deeper cut
__exit__(self, exc_type, exc_val, exc_tb) receives the exception's class, the exception object, and its traceback — or three Nones on a clean exit. Return True and the exception is swallowed on the spot; return None/False (the default) and it propagates after cleanup. That's the whole protocol: any class implementing these two methods works in a with statement — including one that flushes and closes a memmap. Chapter 13 leans on this when errors take center stage.

Every trick in this chapter leaned on a word we haven't formally earned: class. Slots on a class, weakrefs to instances, __enter__/__exit__ methods on a blueprint. Time to build the real thing — a Song, a Playlist, and objects from the metal up →

12Objects & classes

Everything the course has built — values, references, data structures, functions — folds into one move here. By the end of this chapter you'll design your own types: a Song and a Playlist class that bundle data with behaviour, inherit and override each other, and teach Python what +, == and len() should mean for objects you invented.

Data and behaviour, finally in one place

So far a song's data and a song's behaviour have lived apart. The data sat in a dict — {"title": "Levels", "artist": "Avicii", "seconds": 200} (chapter 7). The behaviour sat in free-standing functions (chapter 9). They travel separately: every function must be handed the right dict, and nothing stops you handing it the wrong one — hand a pizza-order dict to a play-song function and Python won't blink until it crashes mid-show, hunting for a "title" that isn't there. A class ends the separation. A class is a blueprint: one written description of what every song has (title, artist, seconds) and what every song can do (play). An object — also called an instance — is one concrete thing stamped out from that blueprint, with its own data filled in. One class Song can build a thousand Song objects, the way one architectural drawing builds a whole street of houses. And this is chapter 3's picture again, unchanged: each instance is an object living on the heap, and levels is just a name holding a reference to it. id(levels) still works; aliasing still applies. You haven't left the memory model — you've learned to design your own kind of object for it.

python
class Song:                                 # the blueprint — written once
    def __init__(self, title, artist, seconds):
        self.title = title                  # store each value ON this new object
        self.artist = artist                # self = the object being built —
        self.seconds = seconds              # fully unpacked in the next section

    def play(self):                         # behaviour lives WITH the data
        return f"Playing {self.title} by {self.artist}"

levels = Song("Levels", "Avicii", 200)      # instance no. 1
wake   = Song("Wake Me Up", "Avicii", 247)  # instance no. 2
print(levels.play())                        # Playing Levels by Avicii
the blueprint — written once class Song title artist seconds __init__() play() the heap — where objects live (ch 11) Song instance title 'Levels' seconds 200 @ 0x55a1…c0 Song instance title 'Wake Me Up' seconds 247 @ 0x55a1…f8 Song(…) levels wake names hold references (ch 3)
Fig — One blueprint (the class), many objects (instances) on the heap; each variable holds a reference, exactly as in chapter 3.

A class stamps out objects. But what actually runs when you write Song("Levels", "Avicii", 200) — and who fills in that mysterious self? →

self is not magic — it's the instance

Two questions hide inside Song("Levels", "Avicii", 200). First: what runs? Calling a class builds a blank Song object on the heap, then automatically calls the class's __init__ method — the initializer — to fill it in. Second: what is self? Count the parameters: def __init__(self, title, artist, seconds) has four, but you passed three arguments. The extra one, self, is the brand-new instance itself, delivered by Python. So self.title = title means: on this particular object, create an attribute named title. Each call to the class hands __init__ a different self — that's why levels and wake hold different titles. A method is nothing exotic: it's a function (chapter 9) that lives on a class and receives the instance as its first parameter. The dot-call is sugar. levels.play() is exactly Song.play(levels): the object left of the dot is shipped in as self. One rewrite rule, zero magic.

python
levels.play()          # what you write
Song.play(levels)      # what Python actually runs — identical result

levels.play            # no parentheses = the method object itself (ch 9)
levels.play()          # the parentheses make it run
you write levels.play() Python rewrites the dot call Song.play(levels) what actually runs class Song def play(self): self = the instance itself, stored once on the class the instance arrives as self
Fig — levels.play() is sugar: Python runs Song.play(levels), so the instance arrives inside the method as self.
🧩
Created first, initialized second
Song("Levels", "Avicii", 200) first builds an empty Song object on the heap, then calls __init__ with that new object as self to fill it in. __init__ doesn't create — it initializes. (Creation is done by __new__, which you'll almost never touch.)
💡
self is not a keyword
It's only the conventional name of a method's first parameter. You could call it me and Python wouldn't care — but every Python reader on earth expects self, so use self.
Wait —
if play() is stored once on the class, not inside each object… where exactly does each song's own title live? And what happens if the class and the instance both have an attribute with the same name?

Two kinds of attribute, one lookup rule

Everything you set through selfself.title, self.seconds — is an instance attribute: stored per object, in that object's own little dict (its __dict__). But a variable written directly in the class body — like platform = "iolinked FM" in the code below — is a class attribute: stored once, on the class object, visible to every instance. When you read wake.platform, Python runs one fixed lookup: (1) check the instance's own __dict__ — miss; (2) climb to the class — hit. That rule also explains shadowing: assigning levels.platform = "offline mix" never touches the class copy — it plants an instance attribute that sits in front of it, so lookup now stops at step 1. wake still sees the class value. The memory arithmetic follows directly: a thousand Song objects cost a thousand small per-instance dicts of data, plus one class carrying the shared attributes and every method. (If even those per-instance dicts are too heavy, __slots__ deletes them — chapter 11.)

python
class Song:
    platform = "iolinked FM"        # class attribute — stored ONCE
    def __init__(self, title, seconds):
        self.title = title          # instance attributes — per object
        self.seconds = seconds

levels = Song("Levels", 200)
wake   = Song("Wake Me Up", 247)

print(levels.platform)              # iolinked FM   (found on the class)
levels.platform = "offline mix"     # plants an INSTANCE attribute
print(levels.platform)              # offline mix   (shadows the class copy)
print(wake.platform)                # iolinked FM   (class copy untouched)
wake.platform ? 1 — check the instance first wake — its own data (__dict__) title: 'Wake Me Up' seconds: 213 platform? ✗ not here class Song platform = 'iolinked FM' play() ✓ found — lookup stops 2 — climb to the class
Fig — Attribute lookup checks the instance's own dict first, then climbs to the class — first hit wins.

✗ The myth

Every object carries its own copy of every method — a thousand songs means a thousand play() functions in memory.

✓ The reality

Methods are stored once, on the class object. An instance holds only its data. The dot lookup climbs to the class and borrows play() at call time — which is why fixing a bug in the class fixes it for every instance already alive.

InteractiveOne class attribute, two instances — move it, shadow it
class Song volume = 70 one shared copy, stored on the class lookup climbs to the class levels volume = 90 (its own copy) levels.volume → 70 wake (no volume of its own) wake.volume → 70
levels.volume → 70 · wake.volume → 70
One number on the class serves every instance — until an instance plants its own copy and shadows it.
🚨
A list as a class attribute is ONE list
class Playlist: songs = [] puts a single list object on the class. Every instance's .songs.append(...) lands in that one shared list — the same aliasing trap as chapter 3. Data that belongs to each object must be created in __init__: self.songs = [].

One blueprint, shared behaviour. But what about a new blueprint that's almost a Song — same everything, one twist? Surely you don't copy the whole class →

Inheritance: borrow a class, change one thing

A live recording is still a song — same title, artist, seconds, same idea of playing — plus a venue. You could copy the whole class and edit it; you'd regret it twice, because every bug now exists in two places. Inheritance is the fix: class LiveSong(Song): declares Song the parent (base class) and LiveSong the child. The child gets everything the parent defines — including __init__ itself, if the child doesn't write one. From there it can add (a venue), override (define its own play()), or extend: super().__init__(title, artist, seconds) means "run Song's initializer on this same self first, then continue with my own lines." So where does Python look when you call gig.play()? It follows the MRO — the method resolution order — a fixed list of classes searched in order: LiveSong, then Song, then object, the built-in ancestor of every class. First hit wins; that is all "override" means. And because a LiveSong is a Song, the built-in check isinstance(gig, Song) — "was this object stamped from this class, or from any child of it?" — returns True.

python
class LiveSong(Song):                            # Song is the parent
    def __init__(self, title, artist, seconds, venue):
        super().__init__(title, artist, seconds) # Song fills the basics
        self.venue = venue                       # then add the new bit

    def play(self):                              # override the parent's
        return f"Playing {self.title} live at {self.venue}"

gig = LiveSong("Levels", "Avicii", 260, "Tomorrowland")
print(gig.play())        # Playing Levels live at Tomorrowland
print(gig.seconds)       # 260 — set by Song.__init__, via super()
print(isinstance(gig, Song))   # True — a LiveSong IS a Song
object ← every class ends here class Song __init__ · play() class LiveSong(Song) __init__ · play() ← overrides inherits inherits super().__init__() run the parent's setup too MRO — search order 1 LiveSong 2 Song 3 object first hit wins
Fig — LiveSong inherits from Song; the MRO is the fixed search path LiveSong → Song → object, and super() is how a child extends rather than replaces.
⚠️
Forget super().__init__() and the parent never runs
If LiveSong.__init__ skips super().__init__(title, artist, seconds), no code ever sets self.title. The object is born half-built, and play() dies later with AttributeError. An override replaces; super() is how you extend.
The deeper cut
Python allows multiple parents: class KaraokeTrack(Song, LyricsMixin):. The MRO is then computed by the C3 linearization algorithm, which guarantees two things: every child appears before its parents, and listed parents keep their left-to-right order. Inspect it any time with KaraokeTrack.__mro__ or .mro(). Subtlety worth knowing: super() does not mean "my parent" — it means "the next class in the MRO". In a diamond-shaped hierarchy that distinction is what makes cooperative super().__init__() chains run every initializer exactly once instead of twice. If Python cannot build a consistent C3 order (e.g. class X(A, B) where B is itself a subclass of A listed in conflicting order), the class statement raises TypeError at definition time.
Wait —
if the method search starts at the object's class and happens at call time… then one line like track.play() could run completely different code depending on which object is in track. Couldn't it?

Polymorphism: the object picks the method

It could, and it does — that's the payoff of the whole chapter. Below, one line, track.play(), is the entire call site, yet it produces three different behaviours. The idea is polymorphism ("many shapes"): the same call, resolved differently per object. The mechanism is dynamic dispatch: at run time Python looks at the actual object left of the dot, starts the method search at that object's class, and walks its MRO. The caller doesn't choose the method — the object does. Watch the Remix closely: it defines no play() of its own, so the search climbs to Song — but self is still the Remix, so Song's code prints the Remix's title. Method from one class, data from another: the MRO and self working together. And that's abstraction in action, too: the loop needs to know that every track plays, never how.

python
class Remix(Song):                       # inherits play() untouched
    def __init__(self, title, artist, seconds, remixer):
        super().__init__(f"{title} ({remixer} Remix)", artist, seconds)

playlist = [
    Song("Levels", "Avicii", 200),
    LiveSong("Levels", "Avicii", 260, "Tomorrowland"),
    Remix("Levels", "Avicii", 230, "Skrillex"),
]

for track in playlist:
    print(track.play())        # one line — three behaviours
# Playing Levels by Avicii
# Playing Levels live at Tomorrowland
# Playing Levels (Skrillex Remix) by Avicii
for track in playlist: track.play() Song.play() LiveSong.play() Song.play() ↑MRO Playing Levels by Avicii Playing Levels live at Tomorrowland Playing Levels (Skrillex Remix)… the OBJECT chooses the method, at run time
Fig — One call site, three methods: each object routes .play() through its own class's MRO, so the object — not the caller — decides what runs.
InteractiveSame call, different class — watch the MRO walk
MRO: Song → object track = Song("Levels", "Avicii", 200) track.play() object no play() here class Song ✓ play() runs here class LiveSong(Song) play() — overrides Song's class Remix(Song) no play() of its own Playing Levels by Avicii
play() found on: Song
One line — track.play() — three behaviours. The object picks where the search starts; the MRO picks the path.
🦆
Duck typing
The loop never asks what type track is — it only asks it to .play(). Anything with a play() method qualifies, related class or not: "if it walks like a duck and quacks like a duck, it's a duck." Inheritance is one way to promise a method exists; simply having the method is another. A Podcast class with its own play() slots straight into the playlist.

Want the promise enforced? An abstract base class declares a required method with no body — and Python then refuses to build any object whose class hasn't implemented it. One new piece of syntax below: a line starting with @ is a decorator, a tag placed on the def beneath it that changes how Python treats that function. @abstractmethod tags play as "required — subclasses must write their own":

python
from abc import ABC, abstractmethod

class Playable(ABC):
    @abstractmethod
    def play(self): ...

Playable()   # TypeError: Can't instantiate abstract class Playable...
             # a subclass without play() fails the same way — at build
             # time, not as a surprise crash mid-playlist

The object decides what .play() means. Could it also decide what +, == and len() mean? →

Dunders, @property, and the privacy that isn't

Dunder methods (double-underscore, like __init__) are named hooks: define them, and your objects plug into Python's own syntax. You've been calling them all course without knowing: len(text) runs text.__len__(); a + b runs a.__add__(b); print(x) asks for x.__str__(), falling back to x.__repr__(). Without a __repr__, printing a Song shows the default <__main__.Song object at 0x7f…> — the class plus the object's heap address, id() in hex (chapter 3). Define the hooks and your types stop being second-class: len(party) just works, + merges playlists — that's operator overloading — and == compares values instead of falling back to identity, chapter 4's is.

python
class Song:
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds
    def __repr__(self):                     # what print() shows (!r keeps quotes)
        return f"Song({self.title!r}, {self.artist!r}, {self.seconds})"
    def __eq__(self, other):                # what == means for two Songs
        return (self.title, self.artist) == (other.title, other.artist)

class Playlist:
    def __init__(self, songs):
        self.songs = list(songs)            # own copy — no aliasing surprises (ch 3)
    def __len__(self):                      # what len() reports
        return len(self.songs)
    def __add__(self, other):               # what + builds: a merged Playlist
        return Playlist(self.songs + other.songs)

party = Playlist([Song("Levels", "Avicii", 200)])
chill = Playlist([Song("Wake Me Up", "Avicii", 247)])

print(len(party + chill))     # 2 — len() → __len__, + → __add__
print(party.songs[0])         # Song('Levels', 'Avicii', 200)  (__repr__)
print(Song("Levels", "Avicii", 200) == Song("Levels", "Avicii", 200))
                              # True — value equality, not identity
you write Python calls len(party) party + chill levels == wake print(levels) party.__len__() party.__add__(chill) levels.__eq__(wake) levels.__str__() → __repr__ fallback
Fig — Operators and built-ins are dispatched to dunder methods: the syntax is sugar, the method call is real.

Two last refinements. A @property is a decorator that makes a method read like an attribute: you write levels.minutes with no parentheses, and Python quietly runs the method and hands you the result, computed fresh on each access. And the leading underscore in _seconds is Python's encapsulation: a naming convention meaning "internal — go through the property," enforced by politeness, not by the interpreter. (A matching @minutes.setter can validate writes the same way.)

python
class Song:
    def __init__(self, title, seconds):
        self.title = title
        self._seconds = seconds     # _ = internal: use the property

    @property
    def minutes(self):              # a method that READS like an attribute
        return self._seconds / 60

levels = Song("Levels", 200)
print(levels.minutes)               # 3.3333333333333335 — no parentheses
↺ The thing people get backwards
Most think a double underscore makes an attribute private — that acct.__balance = -100 from outside the class "is an error." It's really name mangling: inside class BankAccount, the name __balance is silently rewritten to _BankAccount__balance. So reading acct.__balance from outside raises AttributeError — not because access is forbidden, but because no attribute has that literal name. And writing acct.__balance = -100 raises nothing at all: it quietly creates a brand-new, unrelated attribute while the real balance sits untouched under its mangled name. Python has no private-access error — only a rename.
python
class BankAccount:
    def __init__(self):
        self.__balance = 100            # stored as _BankAccount__balance

acct = BankAccount()
# print(acct.__balance)                 # AttributeError — name is mangled
print(acct._BankAccount__balance)       # 100 — the mangled name works
acct.__balance = -100                   # NO error: creates a NEW attribute
print(acct._BankAccount__balance)       # 100 — real balance untouched
print(acct.__balance)                   # -100 — the unrelated newcomer
💡
Define __repr__ first
Aim __repr__ at developers — unambiguous, ideally re-typeable as code. Add __str__ only if end users need something prettier. Containers always use __repr__: printing a list of Songs shows every element's repr, never its str.
The deeper cut
Three expert edges. One: defining __eq__ without __hash__ sets your class's __hash__ to None — instances become unhashable, so they can no longer live in a set or serve as dict keys (chapter 7). If equal songs should hash alike, add def __hash__(self): return hash((self.title, self.artist)). Two: a polite __eq__ returns NotImplemented (not False) when other is a foreign type, so Python can try the other operand's __eq__ — the reflected dispatch behind mixed-type comparisons. Three: Python has no method overloading — a second def play(self) in the same class silently replaces the first, because a class body just binds names in order. Want two calling styles? Use default or variable arguments (chapter 9). And one __lt__ plus @functools.total_ordering buys you all six comparisons, which is what lets sorted(playlist.songs) work.

Your Playlist now understands +, len(), and equality — but hand it one bad value and the whole program still dies mid-song with a traceback. Next: errors — how Python signals failure, and how to catch a crash in flight instead of letting it take the playlist down. →

13Errors & robustness

Your program will meet bad input, missing files, and typo'd keys — that's not a maybe. After this chapter you'll be able to read a traceback like a map, catch exactly the failure you expected while letting real bugs surface, raise your own errors, and write an input loop that a keyboard-mashing user cannot crash.

An exception is an object, not a scream

When Python hits something impossible — dividing by zero, indexing past the end of a list — it doesn't just die. It builds an exception: a real object, on the heap like any other, describing what went wrong. ZeroDivisionError, KeyError, ValueError — these are classes, and an error at runtime is an instance of one, carrying a message inside it.

That object then travels — and raising is Python's verb for launching it. A raised exception flies up out of the current line, out of the current function, up through every caller — abandoning each stack frame (chapter 9) mid-sentence as it passes — looking for someone willing to handle it. If nobody is, it reaches the top, Python prints a traceback — the trail of calls it flew through — and the program stops. That's it. An "error" is just an object nobody caught.

python
song = {"title": "Levels", "artist": "Avicii", "seconds": 200}
print(song["genre"])
# KeyError: 'genre'   ← an object of class KeyError, uncaught
song["genre"] line 2 show_song() main() call stack (newest at bottom) KeyError('genre') an object, on the heap uncaught → traceback printed, program stops it flies UP through every caller
Fig — An exception is an object that climbs the call stack; if no frame catches it, Python prints the trail and exits.
🧭
Read a traceback bottom-up
The last line names the exception and its message — start there. The lines above are the flight path, oldest call first. The deepest frame (just above the message) is where it actually blew up.

So an exception is an object looking for a handler. What does "handling" it actually look like →

try / except: a net under the risky line

try marks a block as "this might fail." except SomeError is the net: if that exception type is raised anywhere in the try block, control jumps straight into the except block — the rest of the try block is skipped entirely — and the program keeps going. If nothing fails, the except block is skipped instead.

python
song = {"title": "Levels", "artist": "Avicii", "seconds": 200}

try:
    genre = song["genre"]        # raises KeyError
    print("never reached")       # skipped — control already jumped
except KeyError as e:
    print("missing field:", e)   # missing field: 'genre'

print("still running")           # the program survived

as e binds the exception object to a name, so you can read its message. Notice the jump: print("never reached") never runs. An exception isn't a return value you check afterwards — it's a teleport out of the block, mid-flight.

no exception try: risky line except (skipped) code after exception raised try: risky line 💥 except: handles it code after the JUMP skips the rest of try
Fig — Success flows straight past except; failure teleports into it. Either way, life continues after the block.
Wait —
what if I want code that runs only when nothing failed, and cleanup that runs no matter what?

else and finally: the two forgotten blocks

The full machine has four blocks, and each answers a different question. try: the risky part. except: runs only on failure. else: runs only on success — no exception happened. finally: runs always — success, failure, even if the exception isn't caught, even if you return early. It's the cleanup slot: close the file, flush the memmap, release the buffer — deterministic teardown instead of waiting for the garbage collector (the same job with automates, chapter 11).

python
try:
    seconds = int("200")          # risky: could be "20O"
except ValueError:
    print("not a number")         # only on failure
else:
    print(seconds // 60, "min")   # only on success → 3 min
finally:
    print("done either way")      # ALWAYS runs

Why else instead of just putting the line inside try? Precision. Code in try is code whose failures you're offering to catch. else says: this part depends on success, but if it fails, that's a different bug — don't swallow it with the same net.

InteractiveFlip the failure — watch control flow re-route
try: int(text) except ValueError: else: finally: skipped skipped 💥 ValueError always runs order: try → else → finally
no exception
except and else are opposites — exactly one runs. finally runs on both paths, always.
try except else finally the risky part only on failure only on success ALWAYS except and else are opposites; finally ignores the outcome entirely
Fig — Four blocks, four rules: risky / on-failure / on-success / always.
⚠️
The finally-close trap
finally: file.close() looks safe — but if open() itself failed, the name file was never bound, and your cleanup line raises a fresh NameError. Prefer with open(...) as f: (chapter 11), which only owns cleanup for a file that actually opened.

The net works. But a net that catches everything also catches things you wanted to see →

Catch the error you expect — not all of them

except: with no type (or except Exception: used reflexively) catches every failure — including the typo you just wrote. Misspell a variable inside try and the resulting NameError gets silently eaten by the same net you meant for bad input. The program "works" while being broken. That's the worst kind of bug: an invisible one.

python
track = ("Levels", "Avicii", 200)

try:
    title, artist, seconds = trck   # typo!
except:                              # bare except: swallows the NameError
    print("bad track")               # lies to you — the track was fine

# right: name the failure you expect, let bugs crash loudly
try:
    title, artist, seconds = track
except ValueError as e:              # wrong element count → ValueError
    print("malformed track:", e)

Need to handle two failure modes the same way? Use a tuple: except (ZeroDivisionError, ValueError) as e:. Need different responses? Stack separate except blocks — Python tries them top to bottom and runs the first that matches.

except ValueError Value caught ✓ Name passes through — crashes loudly (good!) bare except: Value Name Type ALL swallowed — your typo is now invisible
Fig — A specific except handles the failure you planned for and lets genuine bugs crash where you can see them.
↺ The thing people get backwards
Most think a crash means you failed; it's really a crash is a gift — an exact address for the bug, free of charge. Robust code isn't code that never crashes. It's code that crashes loudly on bugs and recovers gracefully from the failures it planned for. Silencing everything with a bare except doesn't make software robust; it makes it unfixable.

But being specific means knowing the names. Each built-in type marks one category of impossibility — learn the map and a traceback stops being noise:

python
playlist = [("Levels", "Avicii", 200), ("Titanium", "Guetta", 245)]
song = {"title": "Levels", "artist": "Avicii", "seconds": 200}

int("Avicii")            # ValueError    — right type, impossible value
playlist[5]              # IndexError    — position doesn't exist
song["genre"]            # KeyError      — key doesn't exist
"Levels" + 200           # TypeError     — types don't combine
200 / 0                  # ZeroDivisionError
song.play()              # AttributeError — dicts have no .play
open("missing.m3u")      # FileNotFoundError
t, a = "no-at-sign"      # ValueError    — unpack count mismatch

Two get confused constantly. ValueError: the type was fine but the content was impossible — int("Avicii") hands int() a string just like int("200") does, but no integer lives inside it. TypeError: the operation makes no sense for these types at all — "Levels" + 200 can't even be attempted. Type is the shape; value is what's inside the shape.

the mistake the exception int("Avicii") ValueError playlist[5] IndexError song["genre"] KeyError "Levels" + 200 TypeError 200 / 0 ZeroDivisionError
Fig — Every beginner mistake has a name. Match the last traceback line to this map and you know what to fix.
📇
The unpacking trick
username, domain = email.split("@") is quiet validation for free: a string without exactly one @ splits into the wrong number of pieces, and the unpack raises ValueError automatically — one try/except and you've validated the email's shape.

You can catch errors. Can you also throw one — on purpose →

raise: signalling your own errors

Sometimes the impossible value is legal Python but nonsense in your world. A song that lasts −50 seconds stores fine in a dict — Python has no opinion about negative ints. You do. raise lets you fire an exception yourself, at the moment bad data enters, instead of letting it rot quietly until some distant line divides by it.

python
class PlaylistError(Exception):
    """Something is wrong with this playlist."""

def add_song(playlist, title, artist, seconds):
    if seconds <= 0:
        raise ValueError(f"impossible length: {seconds}s")
    if any(t == title for t, a, s in playlist):   # any(): does at least one match?
        raise PlaylistError(f"{title!r} is already in the playlist")
    playlist.append((title, artist, seconds))

songs = []
add_song(songs, "Levels", "Avicii", 200)   # fine
add_song(songs, "Levels", "Avicii", 200)   # PlaylistError: 'Levels' is already...

raise ValueError("...") builds the exception object and launches it up the stack — the same flight path as any built-in failure. And subclassing Exception takes one line: now callers can write except PlaylistError and catch your failures without touching anyone else's. Pass the message to the constructor; str(e) retrieves it — no need to store it by hand.

("Levels", -50)incoming data add_song()the border check raise ValueError rejected at the door, loudly playliststays clean — never sees it fail at the border, not three functions later when someone computes -50 // 60
Fig — raise at the point of entry: bad data never reaches the structure, and the traceback points at the real culprit.
The deeper cut

All built-in exceptions form one inheritance tree rooted at BaseException. Exception is the branch you catch and subclass; outside it live KeyboardInterrupt (Ctrl-C) and SystemExit, which is why even except Exception — bad as it usually is — still lets you kill the program, while a truly bare except: can eat Ctrl-C. Catching a class catches its subclasses too: IndexError and KeyError are both children of LookupError, so except LookupError nets both. Inside an except block, a bare raise (no argument) re-throws the current exception with its original traceback intact — the idiom for "log it, then let it keep flying." And raise NewError(...) from e chains the cause, so the traceback shows both stories instead of the misleading "During handling of the above exception, another exception occurred."

Wait —
should I check for trouble before the risky line, or just try it and catch the fallout?

Ask forgiveness, not permission

Two philosophies. LBYL — "look before you leap": test every precondition first (if "genre" in song:). EAFP — "easier to ask forgiveness than permission": just do it, catch the exception if it fails. Python's culture leans EAFP, for two hard reasons. First, the happy path pays almost nothing — a try block that doesn't raise costs roughly zero; you check the key once, not twice (once in the if, again in the access). Second, LBYL has a race: between your check and your action, the world can change. Your code confirms playlist.txt exists; a heartbeat later — before open() runs — a sync app or the user deletes it. The check passed, yet the action crashes anyway. The try is atomic: attempting the action is the check, one indivisible step, no gap for the world to slip through.

python
# the input-hardening idiom — a loop no user can crash
while True:
    try:
        seconds = int(input("Song length in seconds: "))
        break                      # only reached if int() succeeded
    except ValueError:
        print("Digits only — try again.")

print("Added a", seconds // 60, "min track.")
LBYL — look before you leap if "genre" in song the gap —world can change here song["genre"] EAFP — ask forgiveness try: song["genre"] — one atomic step except KeyError: handle
Fig — LBYL checks twice and leaves a gap between check and act; EAFP does the thing once and catches the fallout.

✗ The myth

"Exceptions are for emergencies — using try/except for normal control flow is sloppy. Real programmers validate everything up front."

✓ The reality

In Python, exceptions ARE control flow — every for loop ends by catching StopIteration under the hood. try/except ValueError around int(input()) is the canonical idiom, not a hack. Reserve LBYL for checks that are cheap, race-free, and clearer to read.

💡
When the plane lands anyway
For failures you handle-and-continue in production, leave a paper trail: logging.basicConfig(filename='app.log', level=logging.ERROR) then logging.error("bad track: %s", e) inside the except. Recovered errors you never record are errors you'll never fix.
🚫
EAFP is not "silence it"
except ValueError: pass — catch and do literally nothing — is how data corruption ships. Every except must do something: re-prompt, substitute a default, log and re-raise. If you can't name the recovery, don't catch it.

Your playlist now survives hostile input — and every tool of the course is now on the bench. Next: the whole course folded onto one page — every value, every structure, every idiom, plus six exercises that build the playlist app for real →

14Cheat sheet, glossary & the road on

Thirteen chapters ago, all you had was a single bit — a 0 or a 1. Now you can teach Python what + should mean. This chapter is the page you keep open afterwards: every value type on one line, every structure in one table, the fifteen idioms you'll type forever, the vocabulary in exact one-liners — and six exercises that turn the playlist from a list of strings into a working Playlist class. After it, you can build without us.

Every value, one line each

Strip the course to its spine and three facts remain. One: every value is an object on the heap — the data, plus a type pointer that says what it can do, plus a refcount, all boxed together. Two: names are references — a name stores an address, never the thing. Three: containers differ only in how they find things — by position (contiguous memory) or by hash (buckets). Everything else you learned is these three facts wearing different costumes.

Here are the atomic values, one line each:

TypeOne lineMutable?Litmus
inta whole number; Python grows the digits as needednosys.getsizeof(42) → 28 bytes
floata 64-bit decimalno200.0
compleximaginary part written j, not ino1+2j
boolTrue/False; '', 0, None, empty collections are the falsy onesnobool("False") is True
strimmutable text — every "edit" mints a new stringno"Levels"
bytesimmutable raw bytesnob"ID3" — the first 3 bytes of most MP3s
list / dict / setthe mutable containers — whiteboardsyesnext section
tuple / frozensettheir frozen twins — photos, hashablenousable as dict keys
Nonethe absence of a value; what a bare function returnstest with is None, never ==
bit 0 or 1 1 bit ×8 byte 8 bits 1 B ×28 int 42 header+value 28 B 5 refs list of 5 refs + slack 120 B ×1,000,000 dict, 1M keys hash table 205.6 MiB
Fig — The whole space story on one line: 1 bit → 8-bit byte → 28-byte boxed int → 120-byte five-slot list → 205.6 MiB million-key dict.
🔎
Ask the interpreter, not your memory
Four calls settle any argument about a value: type(x) (what is it), id(x) (where is it), sys.getsizeof(x) (how big is its own box), dir(x) (what can it do). Ten seconds at the REPL beats an hour of guessing.
↺ The thing people get backwards
Most think finishing a Python course means syntax memorized; it's really a memory model installed. Names are references. Every value is a boxed object on the heap. Containers find things by position or by hash. Forget a method name and you can look it up in ten seconds — forget the model and no cheat sheet can save you.

Values are the atoms. Every real program then asks a second question — which container? One table answers it for good →

The decision table

Each row is a promise about time and space. The chooser fits in one breath: in order and indexed → list. A record that must never change → tuple. Look things up by name → dict. "Have I seen this?" → set. Work both ends → deque. Millions of raw numbers → array. The table is why:

StructureGet one itemAdd at endFront add/removex in sMemory shape
listO(1) by indexO(1) amortizedO(n) — everything shiftsO(n)contiguous refs to boxed objects; 96.5 MiB at 1M
tupleO(1) by index— immutableO(n)like a list minus growth slack; hashable if contents are
dictO(1) avg by keyO(1) avgO(1) avg on keyshash table; the heavyweight — 205.6 MiB at 1M
set— no indexingO(1) avg addO(1) avghash table of keys only; uniques for free
dequeO(1) ends, O(n) middleO(1)O(1) popleftO(n)linked blocks; both ends cheap
array.arrayO(1) by indexO(1) amortizedO(n)O(n)packed raw values, one C type, fixed itemsize
building 1,000,000 numbers — measured with memory_profiler (chapter 11) dict 205.6 MiB list 96.5 MiB NumPy +7.6 MiB generator ≈ 200 B — it never builds the million
Fig — The memory ranking to carry for life: dict > list > NumPy array > generator. Speedy lookup is paid for in MiB.
📏
The four memory levers
When the profiler complains, chapter 11 gave you four moves: an array instead of a list (raw values, no boxing), __slots__ instead of a per-instance __dict__, a generator instead of a built list (lazy, ~200 B flat), and memmap when the data outgrows RAM entirely.
The deeper cut
The table rounds corners; here are the exact edges. Append is amortized O(1): most appends land in overallocated slack, but occasionally the list outgrows its block and relocates everything — one O(n) hit averaged across many O(1)s. Dict and set are O(1) average, O(n) worst case: if every key hashed into the same bucket, lookup degrades to a scan; CPython randomizes string hashing per process partly so attackers can't engineer that on purpose. The small-int cache (−5..256) is a CPython implementation detail, not a language guarantee — never let is do the job of ==. sorted() is Timsort, and Timsort is stable: equal keys keep their original order, so sort by title first, then by artist, and each artist's songs stay title-ordered inside the artist blocks. And sys.getsizeof is shallow — it measures the container's own box, not what the container references; a short list of enormous strings still reports tiny.
InteractiveSix jobs — pick the right structure
0 / 6
Pick the structure that fits.
Wait —
if dict lookup is O(1), why isn't everything a dict? Scroll back one figure: you pay for those buckets in MiB. Speed and space are a trade, always.

You can now choose a structure in five seconds. Next: thirteen chapters of habits compressed into fifteen lines you'll type for the rest of your life →

Fifteen idioms that carry most programs

An idiom is a decision someone already made well, frozen into a phrase. These fifteen cover a startling share of everyday Python. First, the ones that run — on the playlist, as always:

python
playlist = [
    {"title": "Levels", "artist": "Avicii", "seconds": 200},
    {"title": "Strobe", "artist": "deadmau5", "seconds": 634},
    {"title": "One More Time", "artist": "Daft Punk", "seconds": 320},
]

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

titles  = [s["title"] for s in playlist]               # build
short   = [s for s in playlist if s["seconds"] < 400]  # filter: after the for
by_len  = sorted(playlist, key=lambda s: s["seconds"]) # new list; original untouched
artists = {s["artist"] for s in playlist}              # set comp — uniques for free
length  = dict(zip(titles, [s["seconds"] for s in playlist]))  # two lists -> one dict

with open("playlist.txt", "w") as f:                   # closes itself, even on error
    for s in playlist:
        f.write(f"{s['title']}|{s['artist']}|{s['seconds']}\n")

And the full quick-reference:

IdiomWhat it buys you
enumerate(songs, start=1)numbered loop, 1-based, no i = 0 bookkeeping
dict(zip(titles, seconds))two parallel lists → one dict (truncates to the shorter)
first, *rest = songsunpacking; rest is always a list
songs[::-1] / songs[::2]reversed copy / every second item — slices are new lists
songs.sort() vs sorted(songs)mutate in place, returns None / new list, any iterable
sorted(titles, key=len)shortest title first — a function reference steers the sort, no parentheses
[s for s in songs if cond]filter goes after the for; a ternary goes before it
", ".join(titles)glue n strings in O(n); += in a loop is O(n²)
with open(path) as f:the file closes itself, even mid-exception
try: int(input()) except ValueErrorharden every number that comes from a keyboard
if not playlist:the emptiness check — '', [], {}, set() are all falsy
while True: … breakloop until done, with the exit test in the middle
x if cond else yan expression — fits inside f-strings and comprehensions
min(volume + 10, 100)clamp to a cap without an if
if __name__ == "__main__":run-as-script guard; imports skip it
[ s["title"] for s in playlist if s["seconds"] < 240 ] what to keep the loop the filter — always after the for ["short" if s["seconds"] < 240 else "long" for s in playlist] the ternary rides before the for — it is part of the expression
Fig — One anatomy, two placements: a filter comes after the for; a ternary comes before it, inside the expression.
⚠️
The parenthesis trap, one last time
key=len hands the sort a function to call later; key=len() calls it now, crashes, and never reaches the sort. Same rule in map, filter, and every callback you will ever write: reference, no parens.

Idioms are your hands. Vocabulary is your mouth — twenty-four words in one line each, plus eight myths to leave at the door →

Twenty-four words — and eight myths

Documentation stops being fog the day these words snap into exact senses. Each line below is the meaning this course used, chapter by chapter — no drift, no synonyms.

seconds reference type: int refs: 1 200 0b11001000 object — the whole box boxed — the header rides along refcount — frees at zero immutable — edits make a new object, new id reference — a name storing the object's address, never the object hashable — hash(200) is stable, so this object could key a dict
Fig — Six glossary words pinned to one object. If you can label this picture cold, you own the vocabulary.
TermOne line
objecta chunk of memory on the heap: a value plus the type that says what it can do
referencea name (or list slot, or dict value) storing an object's address — never the object itself
refcounthow many references point at an object; hitting zero frees it immediately
garbage collectorthe backup sweep that finds reference cycles refcounting alone can't free
small-int cacheCPython pre-builds −5 through 256 once; every 200 in your program is the same object
mutablechanges in place, id stays put (list, dict, set) — the whiteboard
immutablenever changes; "editing" builds a new object with a new id (int, str, tuple) — the photo
hashablehas a stable hash value, so it can be a dict key or live in a set; immutables qualify
boxedevery value ships with a header (type pointer + refcount) — why one int costs 28 bytes
dynamic arraya contiguous block that overallocates, then relocates when it outgrows its spot; what a list is
hash tablebuckets addressed by hash(key), so lookup skips the scan; what dict and set are
Big-Ohow cost grows as n grows, constants ignored: O(1) flat, O(n) linear, O(n²) trouble
lazycomputed on demand (range, zip, enumerate, generators); eager = built now, paid now
generatora function frozen mid-run by yield; hands out one value at a time, then is spent
comprehensiona loop folded into one expression that builds a list, dict, or set
first-class functionfunctions are objects; pass the reference, no parentheses: key=len
stack framethe scratch space pushed for each call, holding its local names; popped on return
heapwhere every object lives; frames on the stack hold references into it
LEGBname lookup order: Local → Enclosing → Global → Built-in
closurea function that keeps using names from the scope it was defined in, even after that scope returned
pass-by-object-referencea call binds the parameter to the same object; mutations are shared, rebinding is local
dundera double-underscore method (__init__, __len__, __repr__) Python calls for you at fixed moments
name mangling__name inside a class is silently renamed _ClassName__name — a collision guard, not a lock
MROmethod resolution order: the fixed route Python walks through parent classes to find a method
🧨
When folklore and the interpreter disagree
Every myth below came from real course notes — written by someone who was teaching. The cure is always one line at the REPL: run it. The interpreter outranks any note, any blog post, and this course too.

✗ The myths to unlearn

  • Python is "just interpreted".
  • input() can hand you a number.
  • bool('False') is False.
  • | is how you write "or".
  • append([a, b]) adds two songs.
  • Sets can't change.
  • Python passes copies ("by value").
  • __balance is private — touching it errors.

✓ The reality

  • CPython compiles to bytecode first, then runs it — ch 0.
  • Always a str; cast it yourself — ch 1.
  • Every non-empty string is truthy; only '' is falsy — ch 2.
  • or short-circuits; | is bitwise and doesn't — ch 4.
  • It nests one list inside; extend adds each — ch 6.
  • Sets are mutable; frozenset is the frozen one — ch 7.
  • Pass-by-object-reference: mutations shared, rebinding local — ch 9.
  • Name mangling renames it to _Class__balance; writing to it silently makes a new attribute — ch 12.

Enough reading. The only proof that a mental model exists is a program that runs →

Build it: six steps to the playlist app

Reading builds recognition; building builds recall. Six exercises, each standing on the last, each naming the chapters it draws from. By step six the playlist you've carried since chapter 2 is a real, saving, sorting, class-based app.

💡
The rule for all six
Type every solution from scratch — no copying from earlier chapters. Stuck for fifteen minutes? Re-read only the section the exercise names, close it, and finish from memory. Recall is built at exactly the moment it hurts.
  1. Parallel lists (ch 1, 2, 5) — three lists: titles, artists, seconds, five songs. Print a 1-based menu with enumerate(..., start=1); print total run time as m:ss using sum(), // and %. Check: "Levels" alone (200 s) prints 3:20.
  2. Tuples and sorting (ch 6, 10)tracks = list(zip(titles, artists, seconds)). Loop with unpacking (for title, artist, secs in tracks:). Make a longest-first copy with sorted(tracks, key=lambda t: t[2], reverse=True) and find the longest with max(..., key=...). Check: the original order is untouched — sorted returned a new list.
  3. Dicts and sets (ch 7) — rebuild each track as {"title": ..., "artist": ..., "seconds": ...} via a comprehension. Build the set of unique artists; count songs per artist in a dict using .get(artist, 0) + 1. Check: adding a second Avicii song grows the count, not the set.
  4. Functions and hardening (ch 9, 13) — write add_song(playlist, title, artist, seconds), total_seconds(playlist), songs_by(playlist, artist). Read new songs from input(), guarding the seconds cast with try/except ValueError and re-prompting. Check: typing abc for seconds must not crash the app.
  5. A file that remembers (ch 11, 13)save(playlist, path) writes one title|artist|seconds line per song inside with open(path, "w"); load(path) splits on | and casts seconds back to int; catch FileNotFoundError and return []. Check: save, quit, rerun, load — and seconds compare equal as ints, not strings.
  6. The class (ch 11, 12) — a Song class (or @dataclass) with three attributes and a __repr__; a Playlist wrapping a list with add(), total_seconds(), __len__, __repr__. Stretch: put __slots__ on Song and compare per-instance size; make total_seconds a generator expression: sum(s.seconds for s in ...).

Step six has an exact finish line — make this run, character for character:

python
night = Playlist("Friday Night")
night.add(Song("Levels", "Avicii", 200))
night.add(Song("Strobe", "deadmau5", 634))

print(len(night))             # 2   -- your __len__ at work
print(night.total_seconds())  # 834
print(night)                  # Playlist('Friday Night', 2 tracks, 13:54)
1 lists 2 tuples + sort 3 dicts + sets 4 functions 5 files 6 a class
Fig — Six steps, one app: the same playlist climbing from parallel lists to a Playlist class. Each step reuses the last — nothing is thrown away.

And when the class version plays its last track — where do you point yourself next? →

The road on

An honest map. This course installed the nouns of Python and where they live in memory. What it cannot hand you is mileage — the hundred small programs where the model becomes reflex. Part 2 exists for exactly that: real programs traced line by line — a guessing game, a hangman, an email slicer — with the stack and the heap drawn at every single step, so you watch frames push and objects appear instead of imagining them.

And the road continues — Volume 2: the working programmer picks up exactly here and turns the model into shipped software: files & persistence (data that outlives the process), modules & import (how one script becomes a project — import, pip, virtual environments), type hints (annotations like seconds: int that mypy checks before the program ever runs), testing & debugging (pytest catches the prime bug you watched ship in trace T15), threads & async (one thread juggling thousands of waits — and the honest truth about the GIL), and a capstone that assembles your Playlist into a complete, tested, persistent app. None of it needs more than what you now know.

modules & packaging type hints testing async Part 1 — you are here Part 2 — traces: programs, line by line
Fig — The main road is Part 2's traces; the four branches are doors you open when a project asks, not prerequisites.

Five doors from here. The trace walkthroughs open the hood for real: the guessing game traced line by line — every stack frame pushed, every object born on the heap, drawn at the exact moment it happens. Volume 2 begins where every run ends: the process dies and takes the playlist with it — 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 is the data structures — the thirteen containers your data lives in, built from the bytes up. And the Algorithms Lab lets you grab the controls — race binary search against linear, sort an array by hand, watch a recursion tree explode →