python/volume-1/chapter-05ch 05 / 64 · 22 min

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.

A program that chooses: if / elif / else The for loop: do this for each one range(): counting without storing the numbers enumerate() and zip(): positions and parallel lists
scroll to begin

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.

THE ONE IDEA TO CARRY FORWARD
An 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.

label.pypython
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)                  # standard

Look 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.

One chain, one exitfigure
seconds = 200 seconds > 600 ? False → fall through seconds > 180 ? True label = "standard" seconds > 0 ? (skipped) else (skipped) jump past the chain
Fig 01 — One chain, one exit. The first truthy gate wins, its block runs, and Python leaps to the far side — the gates below (> 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.

Slide the length — watch which single gate opensinteractive
seconds = 200 gate — read top to bottom sets label if seconds > 600 : "epic" elif seconds > 180 : "standard" elif seconds > 0 : "short" else : "empty?" label = "standard"
200
Try 200 ("Blinding Lights") and 245 ("Titanium") — both land on 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.
Fig 01b — Exactly one gate is ever green. Rows above it are red (asked, answered no); rows below it are grey (Python already jumped out and never asked). The line at the bottom is the value label walks away with.
Indentation is not decoration
Nudge a line one level left and it leaves the block — it now runs unconditionally, no matter what the gate said. That's not a style nit; it's a different program. And never mix tabs with spaces: Python refuses to guess and raises TabError: inconsistent use of tabs and spaces. Pick 4 spaces, the standard, and stay there.
Wait — if only the first truthy gate runs, does the order of my 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.
↺ reframe
Stop picturing three separate questions. Picture a sieve with stacked meshes: the value drops in at the top and falls until the first mesh catches it. Everything below that mesh is irrelevant — the value already stopped falling. 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.

shapes.pypython
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")
Three shapes, three meaningsfigure
NESTED if outer: if inner: inner runs only if outer was True ELIF CHAIN if bucket_1: ✓ elif bucket_2: ✗ else: ✗ exactly ONE runs SEPARATE IFS if check_A: ✓ if check_B: ✓ independent — both can run depends-on → nest · one-of-many → elif · applies-to-all → separate ifs
Fig 02 — Three shapes, three meanings: nesting encodes dependence, an elif chain encodes exclusivity (one winner), and separate ifs encode independence (any number can fire).
ELIF IS ONE KEYWORD, ON PURPOSE
Many languages have no 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. →

Wait — you can stack a comparison the way maths does: 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.
A GATE'S ANSWER IS A NUMBER
Python's bool is secretly a kind of int: True is 1 and False is 0isinstance(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.
Wait — 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.
Wait — if only the first truthy gate runs, is the code in a later branch even looked at? No — not even for errors. Put 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.
Trace3 step the machine — idea · code · memory move together
One chain, one exit — stepping the gates for a 200-second song
An if / elif / else chain is a stack of gates read top-down. Python opens the first truthy gate, runs its block, and leaps clear of the rest — which are never even evaluated. Press Next and drop the token through.
if · elif · elsefirst match winsseconds = 200
The falling-token sieve — first gate to say yes catchesif-chain · top-downinit
token drops ↓
200
walks away with
«unset»
In plain words
Under the hood
SETALU · the test
Program · classify.py
variables · type
Memory · registers + label
registers — the running state
label str · the branch's result
«unset»
gates asked 0 gates skipped 0
what's happening
beat 1 / 1

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.

THE ONE MOVE TO CARRY FORWARD
For each item the iterable hands you: bind the loop variable to it, then run the block. Bind, run, bind, run — until there are no items left. Everything else in this section is a consequence of exactly those two steps.

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.

playlist.pypython
# 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.

One label, hopping across the list — and where it landsfigure
seconds one label, re-bound each turn of the loop after the loop: still bound to 245 200 194 247 320 245 lengths — the list being walked
Fig 1 — The loop variable is one label hopping from item to item. The dashed hops are the turns it took; the solid arrow is where it's left standing when the walk ends — on the last item, 245.
Wait — if the loop variable is only born inside the loop, what happens when the list is empty? Then the block never runs, the name is never bound at all, and reading 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.

Step the loop — watch the binding move and the total growinteractive
for seconds in lengths: # walk the 5-song playlist seconds → not yet bound before the loop — the iterator has handed out nothing yet total (accumulator) total = 0
0 of 5
One step = one turn of the loop: the iterator hands over the next song, 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.
Fig 2 — Bind, run, add — five times. The gold card is the item just handed to seconds; green cards have already been folded into the total; the bar is the accumulator, filling from 0 to 1206.
↺ reframe
A 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_is_sugar.pypython
# `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, unchanged
A for loop, unwrapped: iter → next → StopIterationfigure
the iterable lengths [200,194,…] iter() the iterator iterator remembers its place ▸ cursor next() one item 200 then 194, … cursor runs off the end → next() raises StopIteration → the loop stops
Fig 3 — Every for 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.

SAME THREE LINES, FOUR ANSWERS
The accumulator is one skeleton — init before, update inside — wearing different clothes. Keep the shape and change only the update: 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. →

Wait — a 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.
Wait — the accumulator doesn't only sum — swap the update and it counts. Take the byte 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.
THE ACCUMULATOR, IN ONE WORD
The three-line "start at zero, add each item" loop is so common Python ships it prewritten: 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.
THE THROWAWAY NAME
When you only want the repetition and don't care about the item, the convention is a bare underscore: 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."
Trace3 step the machine — idea · code · memory move together
The for loop, unwrapped — iter, next, and a total climbing to 1206
A 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.
5 songs1 accumulatortotal 1206
The idea — a cursor walks the list, the gauge fillsit · list_iteratorinit
⟂ Stop­Iteration
0
1206
0accumulator → goal 1206
In plain words
Under the hood
ITERALU · the operation
Program · playlist_total.py
variables · type
Memory · registers + object
registers — the running state
lengths list · 5 song-lengths, fixed — total & it live alongside
what's happening
beat 1 / 1

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.

THE ONE IDEA TO CARRY FORWARD
A 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.
counting.pypython
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.

A recipe versus a million cooked mealsfigure
range(1_000_000) — the recipe start=0 stop=1000000 step=1 48 bytes — total each number made on demand: start + step × i used once, then forgotten list(range(1_000_000)) — the meals 0 1 2 3 999999 … 999,996 more slots, each an 8-byte arrow … ~8 MB of slots plus a million integer objects to point at every element already exists in RAM
Fig 1 — A range stores the recipe (48 bytes); a list stores every cooked meal (~8 MB and climbing). Laziness is the whole reason a loop over a billion things is still cheap to start.

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.

Turn the step knob — the stop is a fence, not a stoneinteractive
range(0, 20, 3) start=0 (in) stop=20 (out) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 0, 3, 6, 9, 12, 15, 18 7 numbers · the range object stays 48 bytes, whatever the step
step = 3
Drag the step from 1 (every number, 0–19) up to 6 (0, 6, 12, 18). The green dots are exactly what the loop would visit; the red 20 stays dark forever, because stop is a fence you count up to, never onto.
Fig 2 — One knob, live: raising the step spreads the visited numbers apart, and the stop value is never among them. The count changes; the 48-byte object does not.
Wait — why exclude the stop at all? It feels like an off-by-one waiting to bite. Two gifts make up for it. First, the length falls out by plain subtraction: 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.
↻ reframe
Stop thinking of 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.
lazy.pypython
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 down

Watch 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.

Walk the loop — the numbers don't exist until you arriveinteractive
for track_no in range(1, 6): the range object start = 1 stop  = 6 step  = 1 48 bytes — always 1 2 3 ? ? visited not yet computed value = start + step × i = 1 + 1 × 2 = 3 ▶ now playing: track 3 kept in memory: 0 of the five numbers — each is computed, used, forgotten
i = 2
Step from i = 0 to 4. The gold box is the number that exists right now; the dashed ? boxes are numbers Python hasn't bothered to make yet. A real list would already hold all five; the range holds none of them.
Fig 3 — Iteration is arithmetic, not retrieval: one start + step × i per turn, computed then discarded. That is what "counting without storing" looks like in motion.
COUNTING DOWN, AND THE EMPTY RANGE
With a positive step, the start must sit below the stop or you get nothing at all: 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? →

TEN OR TEN MILLION — SAME WEIGHT
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.
Wait — 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.
Wait — asking 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.
↺ reframe
Two ranges count as equal when they'd produce the same sequence — not when their knobs match. So 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.
Trace3 step the machine — idea · code · memory move together
Counting without storing — how range(0, 250, 50) stays 48 bytes
A range never keeps its numbers. It writes down three knobs — start, stop, step — and mints each value with one start + step × i the instant the loop asks, then throws it away. Press Next and watch numbers get born and forgotten while the object never grows.
5 valuesstep 5048 bytes
The idea — a recipe, not a listrange · 48 Binit
recipe
start0
stop250
step50
48 bytes
·
250
stop · fence
In plain words
Under the hood
SETALU · the operation
Program · lazy_range.py
variables · type
Memory · registers + object
registers — the running state
songs range · a 3-knob recipe — 48 bytes, immutable
what's happening
beat 1 / 1

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:

by_hand.pypython
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 there

Those 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).

enumerate.pypython
songs = ["Blinding Lights", "Titanium", "Levels"]

for n, title in enumerate(songs, start=1):
    print(n, title)
# 1 Blinding Lights
# 2 Titanium
# 3 Levels
THE ONE IDEA
When you catch yourself writing range(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: every item, tagged with its positionfigure
enumerate(songs) hands back one (index, value) tuple per turn Blinding Lights Titanium Levels ( 0 · 1 , 'Blinding Lights' ) ( 1 · 2 , 'Titanium' ) ( 2 · 3 , 'Levels' ) 0 1 2 default, from 0 · 1 2 3 with start=1
Fig 04a — enumerate tags each item with its index and hands both back as one tuple. start=1 only shifts the counter; the values never move.
Wait — is that pair a real object, or just loop syntax? Real. Grab one before it gets unpacked and it's an honest tuple: 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.
Wait — 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.

zip.pypython
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 — close the zipper tooth by toothinteractive
zip(titles, artists) walks both lists in lockstep titles · 3 artists · 4 Blinding Lights Titanium Levels (no 4th title) The Weeknd David Guetta Avicii Daft Punk #0 #1 #2 ✂ no partner closed tooth #1 → ('Titanium', 'David Guetta') pairs produced: 2
2
Drag from 0 to 4. Each notch closes one tooth — a title meets the artist at the same position, in green. Push past 3 and the titles are gone, so the 4th artist ('Daft Punk') never finds a partner and zip simply halts. The pair count never reaches 4.
Fig 04b — zip fastens the two lists one matched pair at a time and stops the instant the shorter list runs dry — the overhang is dropped without a word.
THE SILENT TRUNCATION
zip never warns you that your lists disagree — it just quietly returns the shorter count, and a dropped element is exactly the kind of bug that hides in that silence. Python 3.10 added a seatbelt: 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.
Two reflexes
Writing 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.
↻ reframe — the zipper runs both ways
A zip fastens two rows into pairs; push the pairs back through with a star and it unfastens them. 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:

escape_hatches.pypython
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. →

TWO LISTS INTO A LOOKUP
Feed zipped pairs to 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.
Wait — run the zipper backwards and it transposes a table. 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.
SPEND IT ONCE
A zip is a lazy iterator, and iterators are single-use. 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.
Trace3 step the machine — idea · code · memory move together
Closing the zipper — three pairs, and the fourth artist left dangling
zip walks two lists in lockstep, welding position 0 to position 0, 1 to 1 — emitting one tuple per turn. It stops dead the instant the shorter list runs out, dropping the overhang without a word. Press Next and watch the teeth fuse.
titles · 3artists · 4pairs · min = 3
The zipper — titles ↔ artists, welded row by rowzip · lockstepinit
pairs produced 0zipper open
no tuple yet
In plain words
Under the hood
ZIPALU · the operation
Program · zipper.py
variables · type
Memory · iterators + object
registers — the running state
pairs_produced list · tuples welded so far
what's happening
beat 1 / 1

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.

THE ONE IDEA TO CARRY FORWARD
A 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.
The anatomy of a while loopfigure
loop back — re-ask every turn check the condition is it truthy? the body runs (change the world) truthy code after the loop falsy — the only way out
Fig 05 — Check, then body, then check again — forever, as long as the check is truthy. The body loops straight back to the condition; the single exit is a check that comes up falsy. There is no way to leave from the middle of a turn (yet — that's the next section).

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:

player.pypython
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 -1

The 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.

Step through the loop — watch the world changeinteractive
loop back · re-check battery > 0 and queue ? the check run the body pop · drain · print truthy continue below the loop falsy → exit here battery 5 queue (songs still waiting) Blinding Lights · Titanium · Levels Before the loop — battery 5, three songs queued.
step 0 / 8
Drag the slider. Checks (the violet box) and body-runs (the blue box) strictly alternate — the world only changes in the body, the question only gets asked at a check. The gold outline is where you are. The loop leaves only at step 7, where battery > 0 finally comes up and the and short-circuits before queue is ever read.
Fig 05b — The same three-song run, one step at a time. Nine steps: one setup, four checks, three bodies, one exit. Notice the battery falls only on the even steps (the body) and the exit is reached only through a falsy check.
SAY "AS LONG AS," NEVER "UNTIL"
The keyword runs the body as long as the condition is true, so translate your intent that way before you type it. "Keep going until the queue is empty" becomes 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.
Wait — what if the condition is false the very first time it's checked? Then the body never runs — not even once. Start the player with 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.
↺ reframe
Stop picturing a counter ticking down. A 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.
THE INFINITE LOOP
If nothing in the body ever pushes the condition toward falsy, the answer stays truthy forever and the loop never returns. Forget the 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. →

Wait — 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.
Wait — a 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.
PYTHON HAS NO DO…WHILE
Some languages run the body once before the first check (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."
THE EMERGENCY BRAKE
Built a loop whose condition never turns falsy? It runs until the heat death of your terminal. 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:

ask.pypython
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:

scan.pypython
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 fired

Trace 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.

Two exits and a no-break clausefigure
for song in playlist: run the body's checks… continue → next turn break → leave now back to the top code after the loop skips the else else: # no break loop reached the end no break
Fig 06 — continue curves back to the top for the next lap; break bails clean out of the loop and jumps over the else; only the green "no break" path — falling off the end naturally — lets the else run.
↺ reframe
Almost everyone reads a loop's else as "runs if the loop was empty" or "runs if something failed." Both are wrong. Mentally rename it 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.

Slide the cutoff — steer break, continue and the elseinteractive
each turn: seconds < cutoff → continue (skip) · else → queue it and break Blinding Lights 200s ↷ continue Stay 141s ↷ continue Titanium 245s ◼ break → queued Faded 212s · not reached One More Time 320s · not reached
210 s
Cyan songs are turned away by 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.
Fig 06b — Every position of the slider is one run of the loop. Watch the break-point slide right as you raise the cutoff, and vanish entirely once nothing qualifies — the exact moment the else takes over.
Order inside the body is everything
The two 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.
continue in a while can spin forever
A 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.
Wait — if I nest a loop inside a loop, which one does 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. →

Wait — a loop's 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.
READ IT AS "nobreak:"
Python's most-misread keyword stops confusing you the moment you mentally rename the loop's 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?
Wait — which loop does 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.
Nest a loop — and multiply the whole cost by ninteractive
one for-loop is a straight line of work. wrap it in another, then one more — how many times does the body run? TOTAL TIMES THE BODY RUNS · 3 LOOPS DEEP 1,000,000 ≈ 1.0 ms of work — even at a billion simple steps a second 1 loop  —  for i: → n 100 runs · 100 ns 2 nested  —  for i: · for j: → n × n 10,000 runs · 10 µs 3 nested  —  for i: · for j: · for k: → n × n × n 1,000,000 runs · 1.0 ms Add two indented lines and n=100 steps become 1,000,000 — a 10,000× blow-up (×n for every level).
n = 100
One 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.
Fig 07 — The cost of nesting, log-scaled so a billion still fits on screen. Each new indented loop doesn't add to the work — it multiplies it by n. The bars climb by roughly equal steps because every level is one more factor of n.
Trace3 step the machine — idea · code · memory move together
Steering a loop — continue, break, and the no-break else lamp
continue abandons just this one lap; break bolts out of the whole loop and jumps over the else; and the loop's else — the no-break clause — fires only if the walk finished with no break ever taken. Press Next and watch the chute, the code and the lamp move as one.
for … elsecontinue · breaktarget Titanium
The two-exit chute — continue loops back · break bolts outfor · elseinit
back to top
else — no-break lamparmed
In plain words
Under the hood
ARMALU · control flow
Program · steer_loop.py
variables · type
Memory · registers + playlist
registers — the running state
playlist list · 4 tuples, fixed
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 05, in working code

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.

Branch on a condition
Ask a question, take one path. if/elif/else, the inline ternary, and match/case all pick exactly one arm and skip the rest.
Loop over a sequence
A for loop hands you one item at a time — from a list, the characters of a string, or the integers of a range.
Steer the loop
while repeats on a condition; break bails out early; continue skips the rest of one pass.
Loop shapes
The for/else clause fires only when no break happened; nested loops sweep a grid, inner running fully for each outer step.
Pair, index, and build
enumerate hands you an index alongside each item; zip walks two lists in lockstep; a comprehension is a loop that builds a list in one line.
end of chapter 05 · six sections
iolinked.com
Written by Ajai Raj