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.
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.
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.)
1011 is 8+0+2+1 = 11. No new math, just base 2.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.
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
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.
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.
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 RETURNThe deeper cut
__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.)
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.
>>> 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.
python --version. If a version number answers, you're armed. On macOS and Linux the command is usually python3.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.
>>> 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 UpNow 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).
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.sep (default: one space) between deliveries and end (default: \n) after the last one.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.
# 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 awayTwo 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:
print().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.
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.The deeper cut
You can watch the compile step happen. The dis module ("disassemble") prints the bytecode of any snippet:
>>> 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.
>>> import os
>>> os.system('cls') # Windows — the screen wipes clean
0
>>> os.system('notepad') # opens Notepad; Python waits until you close it
0Two 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.
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').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.os.system() hands a command to the operating system's shell and gets back an exit status.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:
# 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.
python my_playlist.py: your first program's actual output — comma spacing and \n doing quiet work.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:
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-sensitive — Title and title are different names — and indentation carries meaning, which chapter 5 makes precise.)
seconds = 200: the object exists before the name; = only attaches a label to an address.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.
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.
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 overflowOne 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:
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 is a fixed 64-bit box (fast, approximate); an int stretches to fit any whole number (exact, growable).The deeper cut
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.
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 areTrue. 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.
bool lives inside int (a subtype); None is its own type with a single shared instance.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 point — L 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.
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 assignmentbytes 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 is numbers-with-meaning (code points); bytes is the same wire-level numbers with the meaning stripped.str only exists inside Python, after decoding. When files and networks arrive in later chapters, this type is the doorway."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
"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.
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 livesNotice 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.
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:
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.
seconds = 200 runs right-to-left: object first, then the name, then the binding.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 addressIf 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.
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" toob = 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().
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.
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 movedid after a change = mutation; new id = a replacement object was built.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.
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.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 instantlysys.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.
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 timeThe 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.
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 itgc.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.
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) # 1That 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().
/ 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.
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:02Decoder 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.
// counts whole blocks, % keeps the leftover, / gives the exact ratio. Three questions, three answers.divmod(602, 60) returns both at once: the tuple (10, 2). Whole part and remainder, computed together.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.
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== reads the contents; is reads the label on the object.is on numbers or stringsx 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.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.== 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:
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 runsWhen 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.
and stops at the first falsy operand. The dangerous half of the expression is never touched.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.
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 - 112 & 10 = 8. Each output column depends only on its own two input bits.EXPLICIT, DOWNLOADED, LOVED = 1, 2, 4. Pack them with OR: song = EXPLICIT | LOVED → 5 (0b101). Test with AND: song & LOVED → 4, truthy — the flag is set; song & DOWNLOADED → 0, 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.
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** groups right-to-left: 2 ** 3 ** 2 is 2 ** 9 = 512, not 8 ** 2. It's the only arithmetic operator that does this.(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.
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) # standardThat 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.
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")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:
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.
Watch the whole thing run — drag the slider to step through the loop one iteration at a time.
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.
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 slotsThe deeper cut
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).
songs = ["Levels", "Wake Me Up", "Titanium"]
for n, title in enumerate(songs, start=1):
print(n, title)
# 1 Levels
# 2 Wake Me Up
# 3 Titaniumzip() 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.
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'}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.
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 -1Note 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.
✗ 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)? Usefor. 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.
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 firedTrace 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.
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:
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 array — array 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.
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.
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:
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.
The deeper cut
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.
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)) # 5len 3 in the diagrams). len(playlist) reads that number — it never walks the slots.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.
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 backinsert(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.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:
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 NightsThe cost is honest: copying k references takes O(k) time and O(k) new memory. The original is untouched — a slice never mutates.
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.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.
✗ 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:
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.
("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.
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 fetchfor 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().
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 pairNow 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.
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.
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.
The deeper cut
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.
lengths = { ("Levels", "Avicii"): 200 } # tuple key — frozen, fine
# { ["Levels", "Avicii"]: 200 }
# → TypeError: unhashable type: 'list'("Levels", ["Avicii"]) smuggles in a list, and the whole tuple gets rejected.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.
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.{} 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.)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.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)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.
list, the honest default.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:
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.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.
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.
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 integerarray.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.
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.
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:
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.
And the whole decision map, applied to the thing we've been building all along:
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 intsEvery 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.
def describe(title, artist, seconds):
line = title + " by " + artist
print(line, "-", seconds, "s")
describe("Levels", "Avicii", 200) # Levels by Avicii - 200 sTwo 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 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 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.
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 sTwo 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.)
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)✗ 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:
def add_song(title, /, artist, *, seconds=180):
...
add_song("Levels", "Avicii", seconds=200) # ok
add_song(title="Levels", artist="Avicii") # TypeError: title is positional-onlyAnd 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.
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:
def shout(title):
print(title.upper()) # prints, but returns nothing
result = shout("Levels") # LEVELS appears here
print(result) # NoneWant 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.
None, not nothing.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:
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 —
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.
return the new value; let the caller decide where it goes.plays eclipses the global one.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:
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)]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.
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:
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 3make_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.
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
# Strobeprint(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.
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 → 1401acc * 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
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.
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)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.
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.
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 timenext() cooks exactly one serving.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.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
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.
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✗ 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).
.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.
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 onlygetsizeof weighs the container (136 B), never the 280 B of int objects it points to.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.
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 listNo 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.
✗ 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.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.
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__slots__ deletes the per-instance __dict__: 344 B of skeleton becomes 56 B.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
__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.
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 itselfA 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.
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.
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.
lru_cache: miss = compute + store; hit = instant answer. Memory traded for speed, capped at maxsize.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.
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 donememmap: 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).
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 errorwith guarantees the exit: acquire in __enter__, release in __exit__, on every path out of the block.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.
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 AviciiA 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.
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 runlevels.play() is sugar: Python runs Song.play(levels), so the instance arrives inside the method as self.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.)me and Python wouldn't care — but every Python reader on earth expects self, so use self.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 self — self.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.)
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)✗ 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.
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.
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 SongLiveSong inherits from Song; the MRO is the fixed search path LiveSong → Song → object, and super() is how a child extends rather than replaces.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
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.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.
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.play() through its own class's MRO, so the object — not the caller — decides what runs.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":
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-playlistThe 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.
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 identityTwo 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.)
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 parenthesesacct.__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.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__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
__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.
song = {"title": "Levels", "artist": "Avicii", "seconds": 200}
print(song["genre"])
# KeyError: 'genre' ← an object of class KeyError, uncaughtSo 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.
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 survivedas 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.
except; failure teleports into it. Either way, life continues after the block.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).
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 runsWhy 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.
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.
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 handles the failure you planned for and lets genuine bugs crash where you can see them.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:
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 mismatchTwo 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.
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.
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.
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."
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.
# 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.")✗ 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.
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.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:
| Type | One line | Mutable? | Litmus |
|---|---|---|---|
int | a whole number; Python grows the digits as needed | no | sys.getsizeof(42) → 28 bytes |
float | a 64-bit decimal | no | 200.0 |
complex | imaginary part written j, not i | no | 1+2j |
bool | True/False; '', 0, None, empty collections are the falsy ones | no | bool("False") is True |
str | immutable text — every "edit" mints a new string | no | "Levels" |
bytes | immutable raw bytes | no | b"ID3" — the first 3 bytes of most MP3s |
list / dict / set | the mutable containers — whiteboards | yes | next section |
tuple / frozenset | their frozen twins — photos, hashable | no | usable as dict keys |
None | the absence of a value; what a bare function returns | — | test with is None, never == |
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.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:
| Structure | Get one item | Add at end | Front add/remove | x in s | Memory shape |
|---|---|---|---|---|---|
list | O(1) by index | O(1) amortized | O(n) — everything shifts | O(n) | contiguous refs to boxed objects; 96.5 MiB at 1M |
tuple | O(1) by index | — immutable | — | O(n) | like a list minus growth slack; hashable if contents are |
dict | O(1) avg by key | O(1) avg | — | O(1) avg on keys | hash table; the heavyweight — 205.6 MiB at 1M |
set | — no indexing | O(1) avg add | — | O(1) avg | hash table of keys only; uniques for free |
deque | O(1) ends, O(n) middle | O(1) | O(1) popleft | O(n) | linked blocks; both ends cheap |
array.array | O(1) by index | O(1) amortized | O(n) | O(n) | packed raw values, one C type, fixed itemsize |
__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
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.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:
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:
| Idiom | What 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 = songs | unpacking; 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 ValueError | harden every number that comes from a keyboard |
if not playlist: | the emptiness check — '', [], {}, set() are all falsy |
while True: … break | loop until done, with the exit test in the middle |
x if cond else y | an 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 |
for; a ternary comes before it, inside the expression.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.
| Term | One line |
|---|---|
| object | a chunk of memory on the heap: a value plus the type that says what it can do |
| reference | a name (or list slot, or dict value) storing an object's address — never the object itself |
| refcount | how many references point at an object; hitting zero frees it immediately |
| garbage collector | the backup sweep that finds reference cycles refcounting alone can't free |
| small-int cache | CPython pre-builds −5 through 256 once; every 200 in your program is the same object |
| mutable | changes in place, id stays put (list, dict, set) — the whiteboard |
| immutable | never changes; "editing" builds a new object with a new id (int, str, tuple) — the photo |
| hashable | has a stable hash value, so it can be a dict key or live in a set; immutables qualify |
| boxed | every value ships with a header (type pointer + refcount) — why one int costs 28 bytes |
| dynamic array | a contiguous block that overallocates, then relocates when it outgrows its spot; what a list is |
| hash table | buckets addressed by hash(key), so lookup skips the scan; what dict and set are |
| Big-O | how cost grows as n grows, constants ignored: O(1) flat, O(n) linear, O(n²) trouble |
| lazy | computed on demand (range, zip, enumerate, generators); eager = built now, paid now |
| generator | a function frozen mid-run by yield; hands out one value at a time, then is spent |
| comprehension | a loop folded into one expression that builds a list, dict, or set |
| first-class function | functions are objects; pass the reference, no parentheses: key=len |
| stack frame | the scratch space pushed for each call, holding its local names; popped on return |
| heap | where every object lives; frames on the stack hold references into it |
| LEGB | name lookup order: Local → Enclosing → Global → Built-in |
| closure | a function that keeps using names from the scope it was defined in, even after that scope returned |
| pass-by-object-reference | a call binds the parameter to the same object; mutations are shared, rebinding is local |
| dunder | a 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 |
| MRO | method resolution order: the fixed route Python walks through parent classes to find a method |
✗ The myths to unlearn
- Python is "just interpreted".
input()can hand you a number.bool('False')isFalse.|is how you write "or".append([a, b])adds two songs.- Sets can't change.
- Python passes copies ("by value").
__balanceis 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. orshort-circuits;|is bitwise and doesn't — ch 4.- It nests one list inside;
extendadds each — ch 6. - Sets are mutable;
frozensetis 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.
- Parallel lists (ch 1, 2, 5) — three lists:
titles,artists,seconds, five songs. Print a 1-based menu withenumerate(..., start=1); print total run time asm:ssusingsum(),//and%. Check: "Levels" alone (200 s) prints3:20. - 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 withsorted(tracks, key=lambda t: t[2], reverse=True)and find the longest withmax(..., key=...). Check: the original order is untouched —sortedreturned a new list. - 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. - Functions and hardening (ch 9, 13) — write
add_song(playlist, title, artist, seconds),total_seconds(playlist),songs_by(playlist, artist). Read new songs frominput(), guarding the seconds cast withtry/except ValueErrorand re-prompting. Check: typingabcfor seconds must not crash the app. - A file that remembers (ch 11, 13) —
save(playlist, path)writes onetitle|artist|secondsline per song insidewith open(path, "w");load(path)splits on|and casts seconds back toint; catchFileNotFoundErrorand return[]. Check: save, quit, rerun, load — and seconds compare equal as ints, not strings. - The class (ch 11, 12) — a
Songclass (or@dataclass) with three attributes and a__repr__; aPlaylistwrapping a list withadd(),total_seconds(),__len__,__repr__. Stretch: put__slots__onSongand compare per-instance size; maketotal_secondsa generator expression:sum(s.seconds for s in ...).
Step six has an exact finish line — make this run, character for character:
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)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.
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 →