05Decide & repeat
In Chapter 4 we taught the machine to answer yes or no — comparisons, booleans, and the quiet rule that "" and 0 and every empty collection count as false. In this chapter we spend that answer. A program that only runs top to bottom, once, the same way every time, can't react and can't repeat. That's not a machine; it's a printout. So here's the plan. We give your code the two powers that close the gap: deciding — run this line or skip it depending on what's true right now — and repeating — run one line a thousand times without typing it twice. The whole way through, we keep asking the one thing that matters: what does the machine check, and the instant it has its answer, when does it stop checking? By the end you'll fold a whole playlist into a single running sum, count to a billion without ever storing a number, and walk two lists side by side in lockstep. Every one of those tricks falls straight out of the same yes/no you already built.
01A program that chooses: if / elif / else
Let's start with the smallest decision a program can make. Everything you've written so far marches straight down the page — line 1, line 2, line 3, no choices. But now you want the machine to treat a 200-second single differently from a ten-minute epic. For that you need a fork in the road. That fork is the if statement, and it's the smallest unit of intelligence code can have: ask a yes/no question, and run some lines only when the answer comes back yes.
Here's the part most people skate past: the question doesn't have to be a literal True. Chapter 4 gave you the real rule, truthiness, and if runs on exactly that. "", 0, None, and every empty collection ([], {}, set()) count as False; almost everything else counts as True. So if seconds > 180: works because the comparison hands back a real bool, and if title: works too — it quietly asks "is this string non-empty?" The block that runs is everything indented beneath the if line. This is chapter 2's third axiom finally cashing in: indentation is structure. Python has no curly braces and no begin/end. As it reads your file, its tokenizer measures the leading whitespace of each line and compares it against a stack of the blocks currently open. A line indented deeper than the one before opens a new block (the tokenizer emits an INDENT), and a line that steps back out closes one (a DEDENT). What the block needs is a consistent, deeper indent — any width works, so long as you keep it — and from that Python concludes: this line belongs to that question. Four spaces is simply the convention everyone settles on (it's what PEP 8 fixes), not a width the language enforces.
if is a gate. The condition is truthy → the indented block runs; falsy → the block is skipped whole. Add elif and else and you get a chain of gates where at most one opens. Everything in this section is watching that single behaviour — first match wins, the rest are never even asked.Three keywords braid into one structure. if asks first. elif (read it as "else, if") is asked only when everything above it has already failed. else is the catch-all for whatever fell through every gate. And here is the rule that separates people who think they know if from people who do: only the first matching branch runs. The instant one condition is truthy, Python runs its block and jumps past the entire rest of the chain. The conditions below are never evaluated, not even glanced at.
seconds = 200 # "Blinding Lights", 200 s
if seconds > 600:
label = "epic"
elif seconds > 180:
label = "standard" # <- this one runs
elif seconds > 0:
label = "short" # True too — but never reached
else:
label = "empty?"
print(label) # standardLook at that third gate. 200 > 0 is unmistakably True — 200 really is bigger than 0. And yet label never becomes "short", because Python found its answer one gate earlier at seconds > 180 and stopped there. The later truth is real, but it arrives too late to matter. That isn't a quirk; it's the whole point of a chain: the order of your gates is the order of your priorities.
> 0, else) are never asked, even though > 0 would have said yes.Fig 01 froze the story at seconds = 200. Now take the slider yourself and watch the winning gate move. Push past 600 and "epic" lights up while the rest go dark. Drop to 90 and the win falls through to "short". Drag all the way to 0 and every gate fails, so else finally catches it. Notice what the greyed rows are telling you: below the winner, Python has already left, so those comparisons are never computed.
standard. Then 700 for epic, 90 for short, and 0 to see else catch the fall. The red rows were checked and failed; the grey rows below the winner were never checked at all.label walks away with.TabError: inconsistent use of tabs and spaces. Pick 4 spaces, the standard, and stay there.elifs actually matter? Enormously. Put elif seconds > 0 before elif seconds > 180 and every real song becomes "short" — because 200 trips the > 0 gate first and Python leaves before it ever reaches > 180. An elif chain isn't a set of independent tests; it's a priority list read top-down. Order is logic here.if/elif/elif/else is that stack, and else is the tray at the bottom that catches whatever slipped through every mesh.One structure, but it wears three shapes, and choosing the wrong one is the classic beginner bug. Nest an if inside another when the inner question only makes sense after the outer one passed. Chain with elif when you're sorting a single value into buckets and exactly one bucket should win — the length labels above. Write separate ifs when each check applies to everyone who got this far, independently. Picture a roller coaster. "Tall enough?" is the outer gate. "Child or adult ticket?" only matters once you're through it, so it's nested. "Want the ride photo?" applies to every rider regardless, so it's a sibling if standing on its own. Turn that last one into an elif and you've written a bug: riders who matched an earlier branch would silently skip the photo question.
seconds = 200
explicit = False
if seconds > 0: # outer gate: is this a real track?
if seconds > 180: # nested: only meaningful once it's real
print("long — trim for radio?")
else:
print("fits a radio slot")
if not explicit: # sibling: fires for EVERY real track
print("safe for the party playlist")elif chain encodes exclusivity (one winner), and separate ifs encode independence (any number can fire).elif — they nest an if inside each else, and the code marches ever-rightward off the screen. Python folds that whole staircase into a single flat keyword. elif is not else then if; it's one token (you can check: it's right there in keyword.kwlist), and it keeps every branch of a decision at the same tidy indent.✗ The myth
"Python checks every condition in the chain and runs the ones that are true." So a long elif ladder must be slow, and multiple branches might fire.
✓ The reality
Python stops at the first truthy gate, runs that one block, and never evaluates the conditions below it — provably: put a function call in a later elif and it simply never gets called. At most one branch of a chain ever runs.
The deeper cut — "runs when it's True" is a useful shorthand, and the one-line if
Saying if "runs when the condition is True" is a labelled simplification — clean enough to build on, but not literally what happens. Python never checks for the object True. It calls the condition's truthiness: internally bool(condition), which asks the value's own __bool__ (or, failing that, its length via __len__). That's why if [0]: is truthy — the list has one item — while if []: is falsy, and why if "False": runs (a non-empty string is truthy; the letters don't matter). A comparison like 200 > 180 just happens to hand back an actual bool, so the shorthand and the truth line up. But the machinery underneath is truthiness, every time.
And when a whole if/else exists only to pick between two values, Python offers a one-line form, the conditional expression: label = "long" if seconds > 180 else "short". Same first-match logic, folded onto a single line. Reach for it when the choice is a value, not a block of work.
An if makes its choice exactly once, then it's spent. But a playlist isn't one song — it's five hundred, each needing the same verdict. You'd have to send the code back around to face the next one, and the next… which is precisely what a loop is for. →
if 180 < seconds <= 600: is real Python, and it reads exactly like "between 180 and 600." It isn't two tests glued with and — Python evaluates seconds once and checks it against both fences. Most languages choke on this; here it just works.bool is secretly a kind of int: True is 1 and False is 0 — isinstance(True, int) is True, and True + True is 2. So a comparison doesn't hand back a special yes/no token; it hands back a number you can even index with: ["short", "long"][seconds > 180] picks "long", because seconds > 180 is literally 1 — and reaching into a list by position like that is indexing, which chapter 6 builds in full.if never actually looks for True. It asks the value's truthiness, so if "False": runs its block — a non-empty string is truthy and the letters inside are irrelevant. Flip to if "": and it's skipped. The word "False" spelled inside a string has nothing to do with the boolean False.elif 1/0: as a gate below the winning one and your program never divides by zero and never crashes: Python found its answer earlier and walked out before that line could blow up. A later branch is genuinely never evaluated.02The for loop: do this for each one
You have a playlist and a question: how long does the whole thing run? You could add the five numbers by hand. But five could just as easily be five thousand, so what you really want is a way to say for each song, do the same small thing. That sentence is a for loop, and it is the single line of Python you will write more often than any other.
A for loop takes an iterable — anything that can hand out its items one at a time. A list hands out its elements; a string hands out its characters; a dict hands out its keys; an open file hands out its lines. The loop runs its indented block once for each item handed over, and one such pass is called an iteration. The name you write after for is the loop variable, and here is the part most people skim: it is a plain name, a label, not a box (chapter 3), that gets re-bound to the next item at the top of every turn. The loop copies nothing. It just re-points one label at each object that already exists, in order.
That bare skeleton turns useful the instant you pair it with the accumulator pattern: park a running total in a variable before the loop, then fold each item into it as you go. Start at zero, add each song's length, and when the loop runs dry, the total is your answer.
# seconds per song — "Blinding Lights" first, "Titanium" last
lengths = [200, 194, 247, 320, 245]
total = 0 # the accumulator, starting empty
for seconds in lengths: # bind, run block, repeat
total += seconds # runs 5 times
print(total) # 1206
print(seconds) # 245 — Titanium, still bound!Run it and the last line ambushes everyone. seconds is still alive after the loop, still holding 245 — the length of the final song, Titanium. The loop variable is not sealed inside the block; when the loop ends, nobody wipes it. It stays bound to whatever it pointed at on the final turn. That's occasionally handy for debugging, and occasionally a trap. Reuse the name later assuming it's fresh, and you'll get a ghost from the last loop instead.
seconds afterwards doesn't hand you a stale value — it raises NameError, because the label was never created. "Survives the loop" quietly assumes the loop ran at least once.Watch the whole thing run. Drag the slider to advance one iteration at a time. Each step, the iterator hands over the next song, seconds re-binds, the block runs, and the green accumulator bar climbs toward 1206.
seconds re-binds, the block runs, the total grows. Push past step 5 and next() raises StopIteration — the loop's stop signal — yet seconds is still 245.seconds; green cards have already been folded into the total; the bar is the accumulator, filling from 0 to 1206.for loop is not a counter. It never thinks "now fetch item #3." It asks the iterable one question, over and over — next? next? next? — and stops the instant the answer is "nothing left." There is no index anywhere in sight. That's precisely why the same loop walks a list, a string, a set, and a file line-by-line without changing a character: none of them are asked their length, and some of them (a set, a stream) can't even be indexed.That "ask for the next one until it says stop" isn't a metaphor. It's the literal machinery, and Python will show it to you. A for loop is syntactic sugar: a convenient spelling for a plain while loop built on two functions. iter() turns your iterable into an iterator — a small object that remembers its place — and next() pulls the following item out of it. When the iterator is spent, next() raises StopIteration, and that exception is the loop's stop signal. You haven't formally met try/except or exceptions yet — the errors chapter builds them properly — so for now read the try/except in the desugaring below as one plain idea: ask for the next item; if what comes back is the stop-signal instead of a value, catch it and leave the loop.
# `for seconds in lengths:` is convenience spelling. Underneath:
it = iter(lengths) # ask the iterable for an iterator
while True:
try:
seconds = next(it) # hand over the next item
except StopIteration: # the iterator says "that's all"
break # ... so leave the loop
total += seconds # your loop body, unchangedfor loop unrolls into exactly this: iter() makes an iterator that holds a cursor, next() hands back one item per call, and StopIteration is the "no more items" signal that ends the loop. The sugar just hides the plumbing.✗ The myth
A for loop secretly counts an index from 0 to n−1 and looks up list[i] each time — a C-style counting loop wearing a disguise.
✓ The reality
There is no counter and no indexing. The loop pulls items from an iterator until it's exhausted — which is exactly why it runs unchanged over things that have no index at all: sets, dict keys, generators, an open file.
total += s sums, count += 1 counts, if s > longest: longest = s finds the max, names += s builds a string. Learn the shape once and a hundred loops write themselves.The deeper cut — the iterator protocol, and why the name leaks
Two small honesties. First, the loose phrase "the loop asks the iterable" is a labelled simplification. Precisely: the loop calls iter(iterable) once to obtain an iterator, then calls that iterator's __next__ method on every turn, and StopIteration is how __next__ announces the end. Any object that supplies those two hooks is iterable, which is exactly how you'll make your own later. The iterable is the playlist; the iterator is the finger tracking where you are inside it. (One iterable can spawn many independent iterators, each with its own cursor.)
Second, why does seconds survive the loop at all? Because Python has no block scope. The loop variable isn't special. seconds = <next item> is an ordinary assignment into the surrounding function or module namespace, identical to any other =, so it outlives the for just as any variable would. There is one deliberate exception worth knowing: the variable inside a comprehension like [x*x for x in xs] gets its own private scope and does not leak into the code around it. Python changed that on purpose in Python 3.
One consequence to file away: because a list's iterator tracks its place by position, mutating the list while you loop over it makes the cursor and the shifting items disagree, and you'll silently skip elements. When you must change a collection while walking it, loop over a copy — for s in lengths[:] — or build a fresh list instead.
Summing a list you already have is one thing. But "do this 1,000,000 times" shouldn't mean building a million-element list first just to walk it — that's megabytes of RAM spent to hold numbers you'll throw away. range() is the trick: counting without ever storing the numbers. →
for loop will walk anything that can hand out items — a string deals its letters (for c in "Titanium" gives eight characters), a dict deals its keys — but hand it a lone number and it refuses flat out: for x in 245: raises TypeError: 'int' object is not iterable. The loop doesn't want a value; it wants a source of values.200, which chapter 0 laid out as the switches 11001000, and count how many are on: ones = 0, then for bit in [1,1,0,0,1,0,0,0]: ones += bit leaves ones at 3. Same skeleton — park a total before the loop, fold each item in — just pointed at bits instead of song lengths. That tally is so useful hardware gives it its own instruction: popcount.sum(lengths) gives 1206 in a single call — no total = 0, no +=. Its siblings cover the other classic accumulators: max(lengths) is 320, min(lengths) is 194, len(lengths) is 5. Reach for the hand-written loop only when the update is something they don't already do.for _ in range(3): means "do this three times." _ is an ordinary variable — nothing magic about it — but every Python reader takes it as a signal: "a value is handed over here, and I'm deliberately ignoring it."for loop is sugar for iter()/next(). Each turn the iterator hands over the next length, the loop-variable label re-binds, and the accumulator folds it in — until StopIteration ends the walk. Press Next and watch the cursor advance and the gauge fill.03range(): counting without storing the numbers
You have already met the loop that walks a playlist, taking each song in turn. But sometimes there is no list to walk — you just need to count. Print a banner five times. Number the tracks 1 through 5. Step through every one of the 200 seconds in "Blinding Lights". For all of that, Python hands you range(), and its trick is this: it lets you count to a million without ever holding a million numbers at once.
range(5) lays out the sequence 0, 1, 2, 3, 4 — five numbers, starting at zero and stopping before five. That last word trips up everyone exactly once: the stop value is exclusive, always. You don't get 5; you get everything up to it. Give range two numbers and the first becomes the start: range(1, 6) counts 1, 2, 3, 4, 5. Give it three and the last becomes the step, how far to jump each time: range(1, 11, 2) counts 1, 3, 5, 7, 9, striding two at a time and, as ever, halting before the stop. So there are three knobs: start, stop, step. That is the entire dial.
range is not a list of numbers — it is the recipe for a list of numbers. It writes down just three things (start, stop, step) and computes each value only at the instant the loop asks for it, then forgets it. Counting to a billion and counting to ten cost a range exactly the same: a single small object that never once holds the numbers themselves.for track_no in range(1, 6): # 1 2 3 4 5 — stop is exclusive
print("now playing track", track_no)
for n in range(1, 11, 2): # 1 3 5 7 9 — start, stop, step
print(n)Now the part that earns range its keep, and it is a story about memory, so it matters. A list of a million numbers is a million objects sitting in RAM, right now, all at once. A range is nothing of the sort. On the 64-bit CPython on my machine, sys.getsizeof(range(1_000_000)) reports 48 bytes, and sys.getsizeof(range(10)) reports the very same 48. The size doesn't budge, because the object only ever stores its three knobs; the numbers between them are never born until the loop reaches for them. The equivalent list, by contrast, weighs in at 8000056 bytes, about 8 MB, and that is only the row of slots. Each slot is an 8-byte arrow pointing at a separate integer object living elsewhere, so the real bill climbs higher still. (One honest caveat, so a later chapter doesn't quietly contradict this: CPython keeps the small integers -5 through 256 as shared singletons, so the first 257 arrows point at ints that already existed — but the other ~999,743 distinct values are freshly allocated heap objects. Either way, every one of them exists now.) This produce-on-demand strategy has a name: it is lazy.
Turn the knobs and watch the sequence appear beneath your finger. The dial below fixes the start at 0 and the stop at 20, and lets you change only the step. Notice two things as you drag. The lit numbers spread apart as the step grows, and the stop — that red 20 on the right — is never lit, no matter what. It is the fence you count up to but never touch.
range(0, 20) holds 20 - 0 = 20 numbers, no fiddly "+1" to remember. Second, adjacent ranges tile without a seam or an overlap: range(0, 5) then range(5, 10) cover 0–9 once each, sharing no number and skipping none. Half-open counting is the design that stops off-by-ones, not the one that causes them.range(1_000_000) as "a million numbers." It is a rule for making numbers — the same kind of thing as "the odd numbers" or "count by fives." You would never claim to be holding all the odd numbers; you just know the rule and produce the next one when asked. A range is that intuition, made into an object.import sys
print(sys.getsizeof(range(1_000_000))) # 48 — three knobs, zero numbers
print(sys.getsizeof(range(10))) # 48 — the same tiny object
print(sys.getsizeof(list(range(1_000_000)))) # 8000056 — ~8 MB of slots alone
print(range(0, 100, 5)[3]) # 15 — start + 3*step, no scan
print(999_999 in range(1_000_000)) # True — arithmetic, not a search
print(list(range(10, 0, -1))) # [10, 9, ... 1] — a negative step counts downWatch the laziness happen one turn at a time. Below, a loop runs over range(1, 6) — your five tracks. Drag the play-head and see what the machine actually does on each pass: it takes the recipe on the left (which never changes, and never grows past 48 bytes), does one multiply-and-add to compute the current number, plays it, and drops it. The numbers to the right of the head haven't been computed yet. They don't exist as anything but a promise, which is why they show up as question marks until the head reaches them.
list would already hold all five; the range holds none of them.start + step × i per turn, computed then discarded. That is what "counting without storing" looks like in motion.range(10, 0) is empty — Python counts up, hits the fence immediately, and quits. To run the other way, make the step negative: range(10, 0, -1) gives 10, 9, … 1 (stopping, as always, before 0). A tidy use: range(200, 0, -1) is every second of "Blinding Lights" ticking down to the end — two hundred numbers, still 48 bytes.✗ The myth
range(1_000_000) builds a list of a million numbers up front, so a giant range is slow to create and a memory hog. Best avoid the big ones.
✓ The reality
It builds nothing but a 48-byte object holding start, stop and step. The million numbers never coexist; each is computed the instant the loop asks and gone the instant after. range(10) and range(10_000_000) cost the same to make — reach for the big ones freely.
The deeper cut — a range is stronger than merely lazy
"Lazy" undersells it. A range is a full, random-access sequence, not just a forward stream. It answers len() instantly: len(range(0, 20, 3)) is 7, worked out by arithmetic, not by counting. It indexes in constant time: range(0, 100, 5)[3] is 15, computed straight from start + 3 × step without ever forming the numbers in between. It even slices into another range: range(0, 10)[2:8] is range(2, 8), still 48 bytes, no elements copied.
Its membership test is the sharpest proof. 999_999 in range(1_000_000) is True after a single division — is the target between start and stop, and does it land on the step? — not a million-step walk. I timed the worst case, and the answer is instant and unchanged whether the span is ten thousand or a billion. The same in test on a list must scan element by element; a hundred times more elements takes a hundred times longer, and at a million it already costs seconds. That constant-time reach is why range is strictly stronger than a generator (chapter 10), which can only be consumed forward, once, and cannot tell you its length or hand you its 500,000th item without walking there.
A loop over range gives you each number; a loop over the playlist gives you each song. But what if you need the song and its position at once — or two parallel lists, titles and lengths, marched together in lockstep? →
sys.getsizeof(range(10)) is 48 bytes. sys.getsizeof(range(10_000_000)) is 48 bytes. The object holds three numbers — start, stop, step — and the entire span between them costs nothing, because none of it is stored. The list of ten million, by contrast, weighs about 80 MB of slots alone. Push it to the metal: range(2**32) spans every one of the 4,294,967,296 IPv4 addresses — a list of them would not fit in any machine's RAM — and it is still 48 bytes, because it never holds one address at a time, only start + step × i at the instant a loop asks. Counting is a recipe; storing is a warehouse.range(10**80) describes more integers than there are atoms in the observable universe (roughly 1080) — and it's built instantly and weighs the same 48 bytes as range(10). You could never store that many numbers anywhere in the cosmos; you can trivially hold the rule that would make them.999_999 in range(1_000_000) comes back True after a single division — is the target between start and stop, and does it land on the step? No walking required. Ask the same of the list list(range(1_000_000)) and Python must scan element by element; at a million items that's the gap between instant and sluggish.range(0, 3) == range(0, 3, 1) is True, and every empty range equals every other: range(0) == range(5, 5, 99) is True, because "no numbers" is "no numbers" however you spell the recipe. A range is judged by the numbers it would make, never by the three it stores.04enumerate() and zip(): positions and parallel lists
The for loop you met earlier in this chapter walks a list item by item. But sometimes the item alone isn't enough — you want its position too. "Track 1, track 2, track 3…" The instinct, carried in from other languages, is to invent a counter and reach into the list by hand:
songs = ["Blinding Lights", "Titanium", "Levels"]
for i in range(len(songs)):
print(i, songs[i]) # i is 0, 1, 2 — songs[i] fetches the item thereThose square brackets — songs[i] — mean "hand me the item sitting at position i," counting from 0, not 1 (chapter 6 unpacks indexing in full). It works. But look how much scaffolding it took: a counter you invented, the list's length, and an index back in. That's three moving parts to say one small thing. Python has a tool that says it in one. Hand enumerate your list and every turn it deals you a pair, an (index, value) tuple, which you unpack right there in the loop header. Want human track numbers that start at 1 instead of 0? Write enumerate(songs, start=1).
songs = ["Blinding Lights", "Titanium", "Levels"]
for n, title in enumerate(songs, start=1):
print(n, title)
# 1 Blinding Lights
# 2 Titanium
# 3 Levelsrange(len(...)) only to index straight back in, stop — that is enumerate asking to be born. A good loop hands you what you need; it doesn't make you fish for it.Look closely at what lands in your loop each turn: not two separate things, but one tuple, (0, 'Blinding Lights'). A tuple is just a fixed little group of values held together in order — you'll build the type itself in chapter 6 — and writing n, title = (0, 'Blinding Lights') is tuple unpacking: Python splits the group open and binds each piece to a name, left to right. That is exactly what the loop header for n, title in … does on every turn: catch the pair, unpack it into n and title, run the body. The figure below shows the whole list unrolled into those pairs, with the default 0-based index in blue and the start=1 index in gold beside it.
enumerate tags each item with its index and hands both back as one tuple. start=1 only shifts the counter; the values never move.next(enumerate(songs)) returns (0, 'Blinding Lights'), of type tuple. The two-names-in-the-header move is ordinary tuple unpacking — the same trick as a, b = b, a — not special for magic.zip's lockstep is the very machine from chapter 0. Line up a byte's eight bits with their place-values and let zip pair them: zip([1,1,0,0,1,0,0,0], [128,64,32,16,8,4,2,1]) deals (1,128), (1,64), (0,32), … in step, and summing bit*place across the pairs rebuilds the number the switches spell — here 200. zip isn't only a playlist convenience; it's the operation that reconstructs a value from its bits.enumerate solved "item plus its position." Its sibling zip solves a different everyday knot: parallel lists. Titles live in one list, artists in another, and the only thing tying "Titanium" to "David Guetta" is that they sit at the same position. zip(titles, artists) walks both lists at once and, every turn, hands you a single tuple with the matching pair — position 0 with position 0, 1 with 1 — like the two rows of teeth on a zipper meeting one pair at a time. Feed the result to dict() and those pairs become key–value entries.
titles = ["Blinding Lights", "Titanium", "Levels"] # 3 items
artists = ["The Weeknd", "David Guetta", "Avicii", "Daft Punk"] # 4 — one extra
for title, artist in zip(titles, artists):
print(title, "—", artist)
# Blinding Lights — The Weeknd
# Titanium — David Guetta
# Levels — Avicii ← Daft Punk never appears: zip stopped at 3
print(dict(zip(titles, artists)))
# {'Blinding Lights': 'The Weeknd', 'Titanium': 'David Guetta', 'Levels': 'Avicii'}Two behaviours are worth burning into memory. First, when the lists are different lengths, zip stops at the shorter one — silently, no error. The surplus teeth on the longer side simply never engage. Second, exactly like range, both tools are lazy: they manufacture each tuple only when the loop asks for it, and never hold a whole list of pairs in memory. Drag the zipper below and you can watch both facts happen at once.
zip(titles, artists, strict=True) raises ValueError the moment the lengths differ. Reach for it whenever "equal length" is an assumption you are betting on.songs[i] inside for i in range(len(songs))? You want enumerate. Indexing two lists with the same i? You want zip. Between them they retire almost every hand-rolled index you will ever be tempted to write.zip(*pairs) hands the titles back in one tuple and the artists in another — the exact inverse operation. Same tool, run backwards. Once "walk several sequences in lockstep" becomes a single idea in your head, transposing a table's rows into columns stops looking clever and starts looking obvious.✗ The myth
zip and enumerate quietly build a list of all the pairs, which your loop then walks.
✓ The reality
They build a tiny iterator that makes one pair per turn and forgets it. Zipping two ten-million-item lists costs the same 64 bytes as zipping two three-item lists — nothing is precomputed. The flip side of that thrift: an iterator is single-use. Drain it once and it is empty; loop it again and you get nothing.
The deeper cut — what zip really is, and the two escape hatches
enumerate isn't a separate mechanism; it's zip wearing a hat. enumerate(x) yields exactly what zip(itertools.count(), x) yields. The counter is a first list, an endless 0, 1, 2, …, and zip's stop-at-the-shortest rule is what makes it politely end when x runs out. One idea, two names.
Because truncation is the whole game, zip gives you two escape hatches for when the lengths differ on purpose. strict=True turns the silent stop into a loud ValueError. itertools.zip_longest does the opposite: it runs to the longest list and pads the gaps with a fillvalue you choose:
list(zip(titles, artists, strict=True))
# ValueError: zip() argument 2 is longer than argument 1
from itertools import zip_longest
list(zip_longest(titles, artists, fillvalue="?"))
# [('Blinding Lights', 'The Weeknd'), ('Titanium', 'David Guetta'),
# ('Levels', 'Avicii'), ('?', 'Daft Punk')]Both tools scale past two sequences — zip(titles, artists, lengths) deals 3-tuples — and they nest cleanly, so for rank, (title, artist) in enumerate(zip(titles, artists), start=1): numbers your paired rows in a single header. And mind the single-use catch from the reality panel: draining a lazy iterator empties it. z = zip(a, b); list(z) gives the pairs, but a second list(z) gives []. If you need the pairs twice, spend the iterator into a list once and reuse that.
Every loop so far has known its own length — the items run out, and it stops. But the most human loops don't: "keep asking until the password is valid," "keep simulating until the world settles." For those you can't count turns in advance; you loop while a condition holds — and it's on you to make sure the world eventually changes. →
dict() and two parallel lists fuse into a table: dict(zip(["Blinding Lights", "Titanium"], [200, 245])) is {'Blinding Lights': 200, 'Titanium': 245}. Titles become keys, lengths become values, matched by position — the one-line way to weld "these names" and "these numbers" into something you can look up by name. A dict is exactly that name→value lookup, and it earns a whole chapter of its own later; for now just file the trick away — parallel lists can fuse into a table in one call.zip(*rows) turns rows into columns: zip(*[(1,2,3), (4,5,6)]) yields (1,4), (2,5), (3,6). The * spreads the rows back in as separate arguments and zip re-pairs them by position — the exact inverse of the fastening you just watched.z = zip(a, b); list(z) hands you the pairs — but a second list(z) gives [], because the first pass drained it dry. If you need the pairs more than once, pour them into a real list first — pairs = list(zip(a, b)) — and reuse pairs.05while: loop until the world changes
A for loop walks a collection you already hold. A while loop does something stranger and more powerful: before every single turn it re-asks a question, and it keeps going as long as the answer is truthy. That is the whole engine. Read the keyword literally as those three words: as long as the battery holds, as long as the queue has songs, as long as the user hasn't typed quit. The instant the answer comes back falsy, the body is skipped and your program flows on to whatever sits below the loop.
Two traps hide in that one sentence. First, the wording. Read while as until and you write the condition exactly inverted. That's the classic first while-loop bug, where the loop you meant to run "until the queue is empty" either never starts or never stops. Second, the world has to actually change: something in the body must nudge the condition toward falsy, or the answer stays truthy forever and you've built an infinite loop. When that happens, Ctrl+C is the emergency brake. And notice when the question gets asked: before the body, on every lap, including the very first. So if the condition is already falsy the moment the loop is reached, the body runs zero times. A while loop looks before it leaps.
while loop is a question re-asked before every turn. It doesn't know how many laps it will run — it only knows the one condition that would make it stop. You never count the turns; you name the finish line and let the changing world decide when you cross it.Here it is on our playlist. The player has a little battery and a queue of songs, and it plays as long as there's charge left and a song still waiting:
queue = ["Blinding Lights", "Titanium", "Levels"]
battery = 5 # percent
while battery > 0 and queue: # "as long as", never "until"
song = queue.pop(0) # take the next song (lists: ch.6)
battery -= 2
print("playing", song, "| battery", battery)
# playing Blinding Lights | battery 3
# playing Titanium | battery 1
# playing Levels | battery -1The condition leans on two tools from chapter 4 at once. A bare queue is an emptiness test — a non-empty list is truthy, an empty one falsy — and and short-circuits: if the left side is already false, the right side is never evaluated. Watch the third lap. It plays "Levels" and drags the battery down to -1. On the next turn Python checks battery > 0, gets False, and stops right there, never even looking at queue. The loop only ever exits at a check, never mid-body.
battery > 0 finally comes up ✗ and the and short-circuits before queue is ever read.while queue: — not while not queue:. Write the "until" version literally and the loop runs zero times: not queue is already false on a full queue, so Python looks, shrugs, and skips the whole thing.battery = 0 and not a single song plays: Python asks "0 > 0?", hears "no," and jumps straight past the loop. That's what "checked before every turn, including the first" really buys you — a while loop is pre-tested. It can iterate a thousand times, or zero, and zero is a perfectly normal, bug-free outcome.while condition isn't a countdown — it's a question you keep asking the world: "still charged? still songs left?" The loop has no idea how many laps remain, because that's not up to the loop — it's up to whatever the body (or the outside world) does to the answer. You don't program the number of turns. You program the reason to stop.battery -= 2 line above and the player plays "Blinding Lights" until the heat death of your terminal. Ctrl+C is the emergency brake — it raises KeyboardInterrupt and yanks you out. (Sometimes an endless loop is deliberate — a server, a game frame-loop — but then you leave it on purpose, with a keyword you'll meet next.)✗ The myth
"Use for for short loops and while for long ones."
✓ The reality
Length has nothing to do with it — it's about knowledge. If you know what you're iterating over (a list, a range, a string), reach for for. If you only know the condition that ends it ("until the input is valid," "while the queue drains," "as long as the socket is open"), reach for while. A for counts through a thing; a while waits for a truth to flip.
The deeper cut — why "runs while True" is a useful lie
Saying a while loop "runs while the condition is True" is a labelled simplification — close enough to steer by, and worth refining once. The loop doesn't test for the literal boolean True; it tests for truthiness (chapter 4). Python quietly calls bool() on whatever the condition evaluates to, so while queue: keeps going on any non-empty list, while battery: on any non-zero number, while name: on any non-empty string. The value need never be a boolean at all; it only needs to look true.
That's also why while True: is the canonical "intentional forever" loop: the condition is a constant that's always truthy, so the only way out is a keyword that breaks the cycle from inside the body. Python has no dedicated do…while that runs the body first and checks after; the idiom is exactly while True: with a mid-body escape. And there's a stranger companion still. A while can carry an else that fires only when the loop ends by its condition going false. That's the least-known corner of the whole language, and precisely where we're headed.
Both loops can hit a moment mid-turn where you think "we're done here" or "skip just this one." Two keywords cut a hole in the side of any loop — break and continue — and there's that else almost nobody knows about. →
while battery: with no comparison keeps looping on any non-zero charge — and -1 counts as truthy too, since bool(-1) is True. It's testing "is this nonzero?", not "is this positive?" So a battery you meant to stop at empty happily sails past zero into the negatives. When you mean > 0, write > 0.while condition is pure truthiness, so while n: runs as long as n isn't 0. Point it at a number and shrink that number: steps = 0, then while n: n //= 2; steps += 1 keeps halving until n hits 0 — and the count left behind is exactly how many bits n needs. Feed it 200 and steps stops on 8: the loop just measured the width of chapter 0's byte. The finish line was never a counter you set — it was the world (here, n) reaching 0.do … while). Python has no such keyword — and doesn't need one. The idiom is while True: with an if … break at the bottom, which moves the test from the top of the lap to inside it. One shape covers both "check first" and "act first."Ctrl+C is the escape: it raises KeyboardInterrupt, tears you out of the running loop, and drops you back at the prompt — the one reliable exit from a spin you didn't mean to start.06break, continue — and the else nobody knows
A plain for loop is obedient to a fault: hand it a playlist and it will walk every single song, top to bottom, no matter what it finds along the way. Real code is rarely that patient. Sometimes you want to stop the instant you've seen enough; sometimes you want to wave one item past and keep going. Those are the two escape hatches every loop hides: break and continue. Learn them and a loop stops being a fixed march and becomes something you steer.
break is the fire exit. The moment it runs, the loop is over: no more turns, no finishing the current body, execution simply reappears on the first line below the loop. continue is gentler — it abandons only this one turn. Picture a bouncer standing at the top of the loop body. continue turns the current item away at the door and calls in the next one, so every line below the bouncer only ever meets items that got past the check. Same loop, still running; you just skipped the rest of one lap.
Put a break together with a deliberately endless loop and you unlock Python's favourite "ask until it's right" shape. You can't know in advance how many times a user will mistype the input, so you write while True: — a loop with no natural end — and let a break be the one door out, opened only when the answer is finally valid:
while True: # deliberately endless — you can't know how many tries it takes
reply = input("length in seconds? ")
if reply.isdigit():
seconds = int(reply)
break # valid answer → step through the exit door
print("whole seconds only — try again")Without that break, while True would spin forever. With it, the loop runs exactly as many laps as it needs and not one more. Now watch both hatches work in the same loop: a scan across our playlist looking for one song, skipping anything too short to bother with:
playlist = [
("Blinding Lights", 200),
("Stay", 141),
("Titanium", 245),
("One More Time", 320),
]
target = "Titanium"
for title, seconds in playlist:
if seconds < 180:
continue # too short to bother — skip to the next song
if title == target:
print("queued:", title)
break # found it — stop scanning
else:
print(target, "is not on this playlist") # runs only if NO break firedTrace it by hand. Blinding Lights (200 s) clears the length check and isn't the target, so the body just ends and the loop turns over. Stay (141 s) is under 180, so continue waves it past before the second if ever sees it. Titanium (245 s) clears the length check and matches, so Python prints queued: Titanium and break slams the exit. The loop prints exactly one line and stops. Run it and that's precisely what you get.
But look at that else hanging off the loop, not off an if. That is for-else, and it is the most misread punctuation in Python. The rule is simple once you rename it in your head: the else is the no-break clause. It runs only if the loop ran all the way to the end without ever hitting a break. Here a break fired, so the else is skipped and "is not on this playlist" never prints. Change target to a song that isn't there and the loop falls off the end untouched, so the else fires and announces the miss. It's the clean replacement for the clumsy found = False flag you'd otherwise juggle by hand.
else run.nobreak: and it never confuses you again — it fires whenever the loop finishes on its own terms, and a single break anywhere inside is the one thing that cancels it. Empty loop with zero turns? Still no break, so the else still runs.One interactive to make the difference stick. Same playlist, but you set the cutoff — the shortest length you'll accept. Songs below it get turned away by continue. The first one that reaches it gets queued and breaks out. And if you push the cutoff above every song, nothing breaks and the else finally gets its moment.
continue and cost only a glance; the first song tall enough triggers break, and every card after it is never even looked at. Push the cutoff past 320 and even the longest song gets skipped — no break, so the green else verdict finally lights.else takes over.ifs run top to bottom, once per turn — so continue gets first say. In scan.py the length check comes before the target check, which means a short song is thrown out before Python ever asks whether it's the one you wanted. Hunt for "Stay" (141 s) and the scan reports it "not on this playlist" — the continue skipped it, the break never fired, so the else lied about a song that's sitting right there. Swap the two ifs and it's found. A loop body is still just plain code, read straight down.for loop advances on its own, so continue is safe there. A while loop only advances if you move it along — and continue jumps straight back to the condition, skipping any line below it. Put your counter += 1 after a continue and the counter freezes: the same value keeps failing the same test, forever. The fix is a one-line reorder — bump the counter before the continue can fire.break leave? Only the innermost one — the loop it physically sits in. break and continue always mean "this loop," never "all loops." Break out of the inner loop and the outer loop calmly starts its next lap as if nothing happened. To bail out of both at once you either raise the test into the outer loop's condition, or — cleaner — wrap the whole thing in a function and return, which quits every loop on the way out.✗ The myth
A loop's else runs when the loop is empty, or when it "fails" to find something — some kind of error branch stapled to the bottom.
✓ The reality
It's the no-break clause, nothing more. It runs whenever the loop finishes on its own — including zero iterations — and is skipped the instant a break fires. Emptiness and errors have nothing to do with it.
The deeper cut — while-else, and why break isn't a function
The else clause isn't a for quirk. while loops carry it too, and the rule is identical: while … else runs its else when the condition finally goes false on its own, and skips it if a break cut the loop short. "Ran to completion?" runs the else; "jumped out early?" skips it. One mnemonic covers both loops.
And a subtlety worth filing away: break and continue are statements, not functions — there are no parentheses and nothing to call. They're also strictly loop tools. Write break outside any loop and Python won't even start your program: it's a SyntaxError: 'break' outside loop, caught before a single line executes. That's the compiler telling you a fire exit only makes sense inside a building. (One thing break does not skip: a finally block still runs on the way out, but that's a story for the errors chapter.)
You can now steer a loop — stop it, skip a lap, branch on how it ended. But every loop so far has walked a list you typed out by hand. Those lists have machinery of their own: how they grow, why append is instant while stuffing an item at the front is slow, and what a slice actually copies. That's next — Sequences: list & tuple. →
else fires even when the loop ran zero times. Loop over an empty playlist and the body never executes — yet the else still runs, because "no items" is still "finished without a break." That's the tell that it's a no-break clause, not a "found nothing" or "the loop was empty" clause.else to nobreak:. It runs whenever the loop finishes on its own terms — every item walked, or the condition gone false — and a single break anywhere inside is the one thing that cancels it. Not "empty," not "failed." Just: did we get out early?break leave when they're nested? Only the innermost one — the loop it physically sits in; the outer loop calmly starts its next lap. There's no break 2 to escape both at once. The clean way out of every enclosing loop together is to wrap them in a function and return, which unwinds all of them on the way out.for is a straight line of work. Wrap it in another and the body runs n×n times; wrap it once more and it's n×n×n. Drag n to 1000: three innocent indented lines now demand a billion runs — a full second even at a billion steps a second, while the single loop is still done in a microsecond. Every level of indentation multiplies the whole cost by n again — which is why an accidental nested loop is the most common way fast code quietly turns slow.Control flow is where a program stops being a straight line and starts making choices: branching on a condition, looping until the work is done. Press Next and watch the line-marker fork, circle back, and break out.