◈ python mapVol 1 · Ch 10/14
Volume 1 Python, from the metal up · chapter 10

10Conveyors & generators

In Chapter 9 we turned functions into values you can hand around. Now we cash that in. We pour a whole playlist down a conveyor and let machines do the walking. Here's the plan. We map every track through a function, filter for the long ones, then reduce a column of runtimes down to a single total. After that we write the same work as one readable comprehension instead of a hand-cranked loop. And the whole way through, we keep asking the one thing that actually matters: when does the work really happen? The answer is almost never the moment you'd guess. That question is the thread straight into generators. A generator is a function that pauses, a sequence that doesn't exist until you ask for the next item, a million songs modelled in a few hundred bytes. By the end, one thing is obvious: why sorted() hands you a fresh list while .sort() quietly rewrites the old one. And adding "Blinding Lights" at 200s and "Titanium" at 245s, then totalling their runtime, is just two plain lines.

iolinked · chapter 10 — the checkpoints5 steps
$ sections covered in Conveyors & generators
01map and filter: two machines on a conveyor
02reduce: fold many values into one
03Comprehensions: map + filter in one readable line
04Generators: sequences that don't exist yet
05sorted() vs .sort(): the method/function rule

01map and filter: two machines on a conveyor

Let's start with the loop you'd write by hand. You open an empty list, walk the playlist, and append each reshaped item. You did exactly that in Chapter 9, and there's nothing wrong with it. But Python ships two machines that do the walking for you. Picture them bolted over a conveyor belt: items roll past, a machine acts on each one, a new stream flows out the far end. map(fn, stream) takes a function reference — the bare name, no parentheses, Chapter 9's rule. It promises to run every item that rolls past through that function, emitting the reshaped stream. filter(pred, stream) takes a predicate, which is just a function that answers True or False about one item. It keeps only the ones that answer True. Everything else falls off the belt.

Now point them at our playlist and watch what they do. map with an upper-casing function turns every title LOUD; filter with a "longer than five minutes" test keeps only the marathon tracks and lets the radio-length ones drop away. Same conveyor, two different jobs: one machine reshapes every item, the other waves items through or off. And notice — neither one touches the songs on disk. Each spins up a fresh stream flowing out the far end, and the original list just sits there, untouched.

THE ONE IDEA
A map or a filter is not a pile of results — it's a machine wired to a stream, holding perfectly still until you ask it for the next item. Almost everything odd about them (why one prints as an IOU, why it goes empty after a single pass, why it weighs the same whether it wraps four songs or four million) falls straight out of that one fact.
Two machines, one beltfigure
map(fn, songs) — reshape every item 'Titanium' 'Strobe' .upper() 'TITANIUM' 'STROBE' filter(pred, songs) — keep where the answer is True 200 245 320 634 sec > 300 ? 320 634 200 245 dropped ✗
Fig 01 — One belt, two jobs. map runs every item through a transforming machine and emits the same count, reshaped. filter is a gate: items that answer True flow on, the rest (200, 245) fall off. Both hand back a stream, never a finished list.
SYNTAX · lambda — a function with no name on the doorseven shapes — one expression each, and the key= slot they were really built for
lambda: expression — no slots at all. The expression is the answer lambda x: expression — one slot, and no brackets around it lambda a, b=100: expression — several slots, defaults allowed, same rules as def sorted(items, key=lambda s: s[1]) — THE use case: judge every item by s[1] max(items, key=lambda s: s[1]) — one pass, same steering wheel, no sorting filter(lambda s: s[1] > 210, items) — a predicate, written at the place it is read name = lambda x: expression — legal. Write a def instead, and get a real name
what it isAn expression that evaluates to a function object, exactly like def builds. The only difference is that nothing gets a name.
the bodyOne expression. Not one line, one expression. No if block, no loop, no assignment, no return statement.
the returnThere is no return keyword because there is nothing to choose. The expression's value is what the call hands back.
key=The slot it exists for. sorted, max, min, map, filter all take a function, and often the function is one line long.
when a def winsThe moment it needs a name, a docstring, two statements, or a second caller. A named machine reads better and shows up in tracebacks.
you type
double = lambda n: n * 2
print(double(21))
print(type(double).__name__, "|", double.__name__)

pair = lambda a, b: a + b
print(pair(200, 245))

songs = [("Titanium", 245), ("Blinding Lights", 200), ("Strobe", 607)]

print(sorted(songs, key=lambda s: s[1]))
print(max(songs, key=lambda s: s[1])[0])
print(min(songs, key=lambda s: s[1])[0])
print(sorted(songs, key=lambda s: (len(s[0]), s[0])))

def seconds_of(song):
    """Pull the length out of one (title, seconds) pair."""
    return song[1]

print(sorted(songs, key=seconds_of)[0][0], "|", seconds_of.__name__)

zero = lambda: "no slots at all"
print(zero())
print((lambda a, b=100: a + b)(5))
print(list(filter(lambda s: s[1] > 210, songs)))
you see
42
function | <lambda>
445
[('Blinding Lights', 200), ('Titanium', 245), ('Strobe', 607)]
Strobe
Blinding Lights
[('Strobe', 607), ('Titanium', 245), ('Blinding Lights', 200)]
Blinding Lights | seconds_of
no slots at all
105
[('Titanium', 245), ('Strobe', 607)]
where beginners trip
  • A lambda has no name. double.__name__ answers <lambda>, and so does the traceback when it breaks at 3am.
  • Writing return inside one is a SyntaxError. The body is an expression, and an expression already evaluates to something.
  • Assigning one to a name (double = lambda n: n * 2) runs fine and PEP 8 asks you not to. Use def the moment it has a name.
  • In key=lambda s: s[1] you pass the function. Adding brackets on the end calls it now, with nothing to work on.
  • It can only compute. You cannot print and then update a counter inside one, because that is two statements, not one expression.
conveyor.pypython
# a stream of songs — the belt everything rides on
playlist = [
    {"title": "Blinding Lights", "artist": "The Weeknd",           "seconds": 200},
    {"title": "Titanium",        "artist": "David Guetta ft. Sia", "seconds": 245},
    {"title": "One More Time",   "artist": "Daft Punk",            "seconds": 320},
    {"title": "Strobe",          "artist": "deadmau5",             "seconds": 634},
]

# map: a function REFERENCE (no parens!) + the belt to feed it
loud = map(lambda s: s["title"].upper(), playlist)
print(loud)              # <map object at 0x...>  — an IOU, not the titles
print(list(loud))        # ['BLINDING LIGHTS', 'TITANIUM', 'ONE MORE TIME', 'STROBE']

# filter: a predicate (True/False for ONE item) + the same belt
long = filter(lambda s: s["seconds"] > 300, playlist)
for s in long:
    print(s["title"])    # One More Time / Strobe  — the rest fell off

One catch, and it's the whole personality of this chapter: neither machine has done a scrap of work yet. Both return a lazy object. print(loud) doesn't show your titles. It shows <map object at 0x...>, a machine sitting idle. Nothing has been computed. It's a promise, not a result. The work happens only when something consumes the stream — list() around it, a for-loop over it, a sum() pulling on it. And even then it pays up one item at a time, computing each only at the instant it's asked for. That sounds like a mere quirk until you meet the job that needs it. Picture a stream you could never hold all at once: a 50 GB log on an 8 GB laptop, or a sequence with no end. An eager list dies on both. A machine that computes one item on demand sails straight through. That gap is the felt need this whole chapter is built to answer.

⚠ MOST BEGINNERS THINK…ask it twice — it answers, then it lies
Lazy just means late. The machine computes items only when asked — but once asked, the results are kept in there for you to come back to: check a value with in, check another, drain the rest later, like any container.
TYPE THIS — 10 SECONDS
>>> lengths = map(int, ["200", "245", "607"])
>>> 245 in lengths
>>> 200 in lengths
>>> list(lengths)
True
False
[]
A map keeps nothing — the belt only moves forward, and every question is a consumer: the first in ate 200 on its way to finding 245, the second started where the first stopped and ran off the end, so 200 — the first value you fed in — answers False, list() sweeps up nothing, and the only way to ask twice is to cash the stream into a list once and question that.
A map object is an IOU
print(loud) shows <map object at 0x...>, never your titles, because no work has happened yet — the machine is built and idle. It isn't empty, though: it holds two fixed-size references — a pointer to the function and a pointer to the source stream — and nothing else. That's why an unconsumed map over four songs and one over four million songs weigh exactly the same, 48 bytes apiece, while the finished list of four million is tens of megabytes. Constant overhead, never the results. And that flat cost is the whole point: it's what lets you wrap a stream too big to ever fit in RAM — a 50 GB log, an endless counter — the moment section 4 cashes this in.
Pull the belt one item at a time▸ drag me
map(str.upper, songs) — nothing runs until you ask for the next item the stream (source) 'Blinding Lights' 'Titanium' 'One More Time' 'Strobe' map( str.upper ) what you've pulled (result) · · · · loud = map(str.upper, songs) → <map object at 0x2a1f> nothing computed yet · 4 items still owed
0 of 4
At 0 the map is a sealed IOU — printing it shows <map object>, not data. Each notch pulls exactly one title through str.upper and drops it in the result row. Reach 4 and the belt is exhausted: pull again and nothing comes back. That one-item-at-a-time trickle is what list() races through all at once.
Fig 02 — A lazy iterator computes on demand: the result row fills one box per pull, and an un-pulled map holds no titles at all — only the promise to make them. (The address shown is illustrative.)
Wait — I looped over my filter once and it worked; I looped a second time and got nothing. Bug? No — a map or filter is an iterator: a machine with a read-head that only ever moves forward. The first pass drains it to the end, and there is no rewind button. Want to walk the results twice? Pour them into a list once, then loop that list as often as you like.

map keeps one more trick up its sleeve. Hand it several streams and it walks them in lockstep, plucking one item from each per step and handing the whole bunch to your function. map(min, radio, extended) pairs each track's radio-edit length with its extended-mix length and keeps the shorter — [200, 210, 190, 200]. It stops the instant the shortest stream runs dry, so feeding it mismatched lengths never throws. And one honesty note on filter: it keeps every item whose predicate comes back truthy, not literally True. Truthy is simply what you get when Python runs bool() on a value. A short, fixed list of things count as falsy: 0, 0.0, "", None, and any empty container ([], {}). Everything else is truthy. That's why the barest predicate of all, filter(None, stream), needs no function at all. With None in the predicate slot, filter tests each item's own truthiness, drops every falsy one, and keeps the rest.

SYNTAX · map and filter, written outseven shapes — four that build a machine, three that finally pull on it
map(fn, stream) — one item out per item in, each reshaped by fn map(fn, stream_a, stream_b) — lockstep. fn gets one from each, stops at the shortest filter(pred, stream) — keeps the items whose answer is truthy filter(None, stream) — no function at all: drops every falsy item list(map(fn, stream)) — cash the IOU in: this is where work happens for x in filter(pred, stream): — or let a loop pull it, one item at a time sum(map(fn, stream)) — or a fold: no list is ever built
fnA function reference. The bare name, or a lambda. It runs once per item, later, when something pulls.
predA predicate: any function answering True or False about one item. Truthy counts, so 0, "" and [] fall off the belt.
the returnNever a list. A map object or a filter object, both 48 bytes, holding two pointers and no results.
many streamsOnly map takes more than one. It stops the instant the shortest runs dry, so ragged data never raises.
the honest verdictA comprehension usually reads better for an inline expression. map wins when you already have a named function to point at.
you type
titles = ["Blinding Lights", "Titanium", "Strobe", "Levels"]
seconds = [200, 245, 607, 203]

loud = map(str.upper, titles)
print(type(loud).__name__, "- built, and nothing computed yet")
print(list(loud))
print(list(loud), "- the same machine, second pass")

print(list(map(len, titles)))

radio    = [200, 245, 607, 203]
extended = [210, 240, 634, 190]
print(list(map(min, radio, extended)))

print(list(filter(lambda s: s > 300, seconds)))
print(list(filter(None, [0, 200, "", "Titanium", None, [], 245])))
print(sum(map(lambda s: s // 60, seconds)))

import sys
print(sys.getsizeof(map(str.upper, titles)),
      sys.getsizeof(filter(None, seconds)), "bytes, whatever they wrap")

try:
    list(map(str.upper(), titles))
except TypeError as e:
    print("TypeError:", e)
you see
map - built, and nothing computed yet
['BLINDING LIGHTS', 'TITANIUM', 'STROBE', 'LEVELS']
[] - the same machine, second pass
[15, 8, 6, 6]
[200, 240, 607, 190]
[607]
[200, 'Titanium', 245]
20
48 48 bytes, whatever they wrap
TypeError: unbound method str.upper() needs an argument
where beginners trip
  • They are one-shot. The first list() drains the machine; the second one hands you [], with no warning.
  • Pass the reference: map(str.upper, titles). Adding brackets runs upper right now, with no string, and dies.
  • print(m) shows <map object at 0x...>. That is not a broken result. It is a machine that has not been asked for anything.
  • filter keeps truthy, not literally True. A predicate returning 0 or an empty list drops that item.
  • You cannot index or measure them. No m[0], no len(m). Pour it into a list first if you need either.
↺ reframe
Stop picturing map and filter as loops that have already run. Picture them as a recipe pinned to a conveyor: "when someone asks, grab the next song, do this to it, hand it over." Nobody asking means nothing cooking. That's why they cost almost nothing until consumed — and why chaining filter(pred, map(fn, songs)) never builds two full lists in memory, just two machines passing one item at a time down the line.

✗ The myth

map and filter are just a faster, fancier for-loop that hands you back a new list.

✓ The reality

They hand you back no list at all. Each returns a lazy iterator that computes one item only when pulled, remembers nothing behind it, and empties after a single pass. The list appears only when you ask, with list().

The deeper cut — the comprehension twin, and when each wins

Every map has a look-alike written as a comprehension. [f(x) for x in xs] gives the identical list as list(map(f, xs)), and [x for x in xs if pred(x)] matches list(filter(pred, xs)). Swap the square brackets for round ones and you even win back the laziness. (f(x) for x in xs) is a generator, the same compute-on-demand IOU as map, and the very next idea this chapter unfolds. So which do you reach for? Comprehensions read more plainly when the transform is a little inline expression. map shines when you already have a named function to point at, where map(str.upper, titles) beats [s.upper() for s in titles] for sheer economy. Neither is faster in a way you will ever feel, so pick the one that reads.

map turned four songs into four titles; filter kept two survivors. But the playlist's total runtime is one number squeezed out of all four lengths at once — and neither machine folds a stream down to a single value. So which one does? →

Wait — map can walk several streams at once, in lockstep. map(min, radio_edits, extended_mixes) plucks one length from each list per step and keeps the shorter — [200, 210, 190, 200]. And if the two streams are different lengths it never throws: it simply stops the instant the shortest one runs dry. Ragged data in, no crash out.
A REFERENCE, NEVER A CALL
map(str.upper, titles) hands the machine the name str.upper to run later, once per song. Add parentheses — map(str.upper(), titles) — and Python runs upper right now, with no string to work on, and dies before the belt even starts. It's the same chapter-9 rule that governs key= and every callback: pass the function, don't call it.
THE MACHINE IS ITS OWN READ-HEAD
Ask Python iter(m) is m for a map and it answers True — the machine is the cursor walking its own belt. There's exactly one read-head and no rewind, which is the whole reason a second for-loop over the same map finds nothing left: the head is already parked at the end.
ZIP IS THE SAME SPECIES
zip, enumerate, reversed — all lazy one-shot iterators, cousins of map and filter. A zip object weighs a flat 64 bytes no matter how much it pairs, and empties after a single pass in exactly the same way. When something surprises you with an empty second loop, suspect an iterator hiding in plain sight.
Trace3 step the machine — idea · code · memory move together
One item, the whole pipeline — filter(map()) never builds a list
Chaining filter over map builds two idle machines, not data. Ask for one value with next() and it pulls a single song through both — no intermediate list, only one item ever in flight. Press Next and watch the belt move only when pulled.
map + filterlazy chain1 in flight
The pull-through pipeline — one item in flightlazy · streamsbuild A
A · map
idle
B · filter
idle
items in flight: 0 · lists built: 0
dropped
out · next() returned
In plain words
Under the hood
MAKEALU · the gate
Program · pipeline.py
variables · type
Memory · two machines + the output
registers — the pipeline's running state
objects what actually exists in memory
what's happening
beat 1 / 1

02reduce: fold many values into one

map hands back one new item for every item you gave it — many to many. filter keeps a subset — many to some. reduce does the third thing, and the most radical. It takes a whole sequence and folds it down to a single value — many to one. Total runtime of a playlist. The longest track. A merged index. Drop beneath the song titles to the metal, too. A three-minute track is a river of raw audio samples: millions of little integers, each one a byte pattern of the kind chapter 0 first switched on. And reduce is exactly what folds that whole wall of numbers down to the single loudest one, the peak. Any time a pile of things has to collapse into one answer, you are looking at a fold.

It's the only one of the three that Python doesn't hand you for free. You import it: from functools import reduce. And it works by carrying a single running result called the accumulator. Your function takes exactly two parameters, the accumulator always first and the next item second, and returns the new accumulator. reduce seeds that accumulator with a starting value (here 0, for "no seconds counted yet"). Then it feeds the playlist through one song at a time, each step rolling the last result forward: 0+200→200, 200+245→445, 445+203→648, 648+215→863. The last accumulator standing is your answer.

THE ONE IDEA TO CARRY FORWARD
A fold is a single accumulator walking the sequence once, left to right, absorbing one item per step. Give reduce two things — a combine function (acc, item) → acc and a seed — and it does the walking. Everything else in this section is just choosing what to combine and where to start.
fold.pypython
from functools import reduce

total = reduce(lambda acc, song: acc + song["seconds"], playlist, 0)
print(total)          # 863
# fold: 0+200 → 200 | +245 → 445 | +203 → 648 | +215 → 863
Three shapes: map, filter, reducefigure
map many → many filter many → some reduce many → one one value
Fig 04 — The three tools by their shape. map is a one-for-one relabelling, filter is a gate that some items don't pass, and reduce is a funnel — the whole sequence pours out as one value.
Step the fold — one song at a time▸ drag me
Blinding Lights200 s Titanium245 s Levels203 s One Kiss215 s acc = 0 ← the seed 0 s accumulator — seconds folded in so far (full playlist = 863 s)
0 / 4 folded
The accumulator is a snowball: it rolls over each song exactly once and keeps everything it picks up. At step 4 there is nothing left to fold — that last number is the total.
Fig 04b — One accumulator, one pass. Each step swallows the next song's seconds and the green bar grows; the number never resets, it only accumulates.
Wait — the sum fold seeds the accumulator with 0. Swap the combine step to acc * song["seconds"] to get a product instead, and 0 becomes poison — 0 times anything is 0, so every playlist collapses to zero seconds. What single number must the seed be so the first multiply leaves the first value untouched? The seed isn't decoration; it's the identity element of whatever operation you're folding with.
↺ reframe
You are not "reducing" the data — that word oversells it. You are running an ordinary for loop with exactly one variable kept alive across iterations, and reduce is just that loop crystallised into a name: seed the variable, update it once per item, hand back the last value. Every fold you'll ever write is that same three-line loop wearing a different combine step.
THE SEED IS NOT OPTIONAL FLAVOUR
"reduce always starts from a seed" is a useful half-truth — refine it now. The seed is the third argument and you may drop it, in which case reduce grabs the first item as the seed and folds the rest. That changes the edge cases sharply. With a seed, an empty playlist folds cleanly to the seed (0 seconds — correct). Without one, an empty playlist has nothing to start from and raises TypeError: reduce() of empty iterable with no initial value. And a one-song playlist with no seed returns that song's value without ever calling your function. When in doubt, pass the seed.
REDUCE READS LEFT TO RIGHT
reduce is a left fold: it always pairs the accumulator with the next item, marching from the first element to the last. For + the direction is invisible. For anything lopsided it decides the answer: reduce(lambda a, b: a - b, [100, 20, 7]) is ((100 − 20) − 7) = 73, not 100 − (20 − 7). Same reason folding + over ['a','b','c','d'] spells 'abcd' and never 'dcba'.

✗ The myth

reduce is the elegant, functional way to add up a list — real Python programmers reach for it instead of a plain loop, and using sum is for beginners.

✓ The reality

Adding up a list already has a name: sum. So do the other everyday folds — max, min, math.prod, any, all. Each says what it computes; raw reduce only shows how. Save reduce for folds with no name. If your code reads reduce(lambda x, y: x + y, xs), the answer was sum(xs).

The deeper cut — where reduce lives, and the folds that have no name

reduce used to be a built-in. Python 3 demoted it to functools on readability grounds — 'reduce' in dir(builtins) is now False, and functools.reduce is its home. Its full signature is reduce(function, iterable[, initial]), that third argument the seed we just leaned on. The demotion had a point: most folds you'll actually write already have sharper-named tools. sum(...), max(...), min(...), math.prod(...), any(...), all(...) are every one of them a reduce in disguise, each naming its intent instead of spelling out the loop.

So when is raw reduce the right call? When the fold is genuinely nameless. Two cases earn it. First, merging a run of dicts into one index: reduce(lambda acc, s: {**acc, s["title"]: s["seconds"]}, playlist, {}) builds {'Blinding Lights': 200, 'Titanium': 245, 'Levels': 203, 'One Kiss': 215} in a single pass. Second, threading a value through a pipeline of functions, where each step feeds the next: reduce(lambda v, fn: fn(v), pipeline, start). Notice that the "single value" a fold returns can itself be a whole dict or a running object. "One" means one result, not one number. That is the shape no builtin covers, and where reduce still earns its import.

Five moving parts — reduce, map, filter, lambda, and a list() to catch it all — is a lot of machinery for a one-line job. Python looked at map-then-filter and decided most of it was ceremony. Next: the comprehension, which folds a map and a filter into one line you can read like a sentence →

Wait — reduce used to be a built-in you could call bare — then Python's own creator, Guido van Rossum, argued to delete it outright, saying a raw fold is harder to read than the loop it replaces. Python 3 struck the compromise you live with today: not deleted, but exiled to functools. Check it yourself — 'reduce' in dir(builtins) is now False. You have to import the very thing the language would rather you didn't reach for.
THE FOLDS THAT QUIT EARLY
any and all are folds with an escape hatch that raw reduce can't have: any stops dead at the first True, all at the first False. any(s["seconds"] > 200 for s in playlist) answers on the second song and never reads the rest. A hand-rolled reduce always walks the whole sequence — so when a short-circuit matters, use the named tool.
THE SEED IS AN IDENTITY ELEMENT
Summing seeds with 0; multiplying seeds with 1; 'longest so far' seeds with -infinity. The seed is never a random starting number — it's the one value the operation ignores, its identity element, chosen so the first item lands unchanged. That's also why it's the honest answer for an empty playlist: fold nothing with + and 0 is exactly right.
ONE FOLD IS A PERFORMANCE TRAP
reduce(lambda a, b: a + b, words) looks elegant and hides a cost: strings are immutable, so every + builds a whole new bigger string — n steps, each copying everything so far, an O(n²) crawl. "".join(words) is the same fold done in O(n), sizing the result once. Same answer; wildly different bill. The named tool usually knows a trick the raw fold doesn't.

03Comprehensions: map + filter in one readable line

You have a list of songs and you want a new list from it: just the long ones, or every title upper-cased, or each length converted to minutes. In most languages that is a little ritual. You set up an empty accumulator, loop, test, append, then read the accumulator back out. Python has a purpose-built spelling that states the whole intent on one line you can read aloud — the comprehension. Its shape is [expr for x in xs if cond]. It fuses the two oldest moves in all of programming into a single expression that evaluates to a finished container: map (turn each item into something else) and filter (throw some items away).

Read it in three slots, front to back. The expression up front says what each kept item becomes. The for clause names which items you visit. The trailing if is the gate: an item only survives into the result if that condition is True. Drop the if entirely and nothing is filtered — you keep every item, merely reshaped by the expression. That is the whole grammar; everything else is variations on those three slots.

THE ONE SHAPE TO CARRY FORWARD
Every comprehension is the same three slots inside a pair of brackets: result · for · gate. Learn to read it as one sentence — "give me this, for every item, where this is true" — and lists, dicts, sets, and (next section) generators all fall out of one idea.
Anatomy of a comprehension — three labelled slotsfigure
s["title"] the result — what each kept song becomes for s in playlist the loop — visit every song in turn if s["seconds"] > 300 the gate — keep the song only if True [ s["title"] for s in playlist if s["seconds"] > 300 ]
Fig 1 — One comprehension, three slots: result, loop, gate. It replaces a whole build-an-empty-list-and-append loop with a line you can read left to right.
SYNTAX · the list comprehension, in fullfive shapes, each beside the exact loop it replaces — line for line
[expr for x in xs] — TRANSFORM: n items in, n items out out = [] for x in xs: out.append(expr) [x for x in xs if cond] — FILTER: the gate goes AFTER the for out = [] for x in xs: if cond: out.append(x) [expr for x in xs if cond] — BOTH: gate first, expr only for survivors out = [] for x in xs: if cond: out.append(expr) [a if cond else b for x in xs] — TERNARY: in the result slot, and it needs an else out = [] for x in xs: out.append(a if cond else b) [x for row in grid for x in row] — NESTED: left to right is outer to inner out = [] for row in grid: for x in row: out.append(x)
Type 1No gate. Every item is visited and reshaped, so the count out always equals the count in.
Type 2A gate and no reshape. The result slot is the bare loop name, and the count out is smaller.
Type 3Both. Running order is for, then the gate, then the expression — the reverse of reading order.
Type 4The ternary is not a filter. It sits in the result slot, before the for, and every item still comes out.
Type 5Stacked for clauses run outer to inner, exactly as you would type them stacked, and flatten what they walk.
you type
playlist = [
    {"title": "Blinding Lights", "artist": "The Weeknd",   "seconds": 200},
    {"title": "Titanium",        "artist": "David Guetta", "seconds": 245},
    {"title": "Save Your Tears", "artist": "The Weeknd",   "seconds": 215},
    {"title": "One More Time",   "artist": "Daft Punk",    "seconds": 320},
    {"title": "Strobe",          "artist": "deadmau5",     "seconds": 607},
]

# TRANSFORM -- no gate, so every song comes out the far end
print([s["seconds"] // 60 for s in playlist])

# FILTER -- the if goes AFTER the for, and it has no else
print([s["title"] for s in playlist if s["seconds"] > 300])

# BOTH -- the gate runs first, the expression only for survivors
print([s["title"].upper() for s in playlist if s["artist"] == "The Weeknd"])

# TERNARY -- lives in the result slot, and it must have an else
print(["long" if s["seconds"] > 300 else "short" for s in playlist])

# NESTED -- read left to right, exactly as you would stack the loops
grid = [[200, 245], [215, 320], [607]]
print([x for row in grid for x in row])

# TWO GATES -- they simply and together
print([s["title"] for s in playlist if s["seconds"] > 200 if s["seconds"] < 400])

# the loop name never escapes the brackets
squares = [n * n for n in range(4)]
print(squares)
try:
    print(n)
except NameError as e:
    print("NameError:", e)
you see
[3, 4, 3, 5, 10]
['One More Time', 'Strobe']
['BLINDING LIGHTS', 'SAVE YOUR TEARS']
['short', 'short', 'short', 'long', 'long']
[200, 245, 215, 320, 607]
['Titanium', 'Save Your Tears', 'One More Time']
[0, 1, 4, 9]
NameError: name 'n' is not defined
where beginners trip
  • The two ifs are different tools. A filter has no else and sits at the end; a ternary always has one and sits up front.
  • A bare if before the for is a SyntaxError. A ternary after the for silently filters on the wrong thing.
  • The loop name never escapes. After [n * n for n in range(4)], reading n raises NameError — the name lived only in the brackets.
  • Swap the two for clauses in a nested one and row is not defined yet. Reading order is running order.
  • It builds the whole list before the next line runs. Round brackets are how you stop that, and section 04 is where they arrive.
  • Past two clauses, write the loop. Compressing a thing you cannot read at a glance is not a win anyone collects.

Two placements trip up everyone, and they are worth burning in now, because both use the word if. A filtering condition goes after the for: [x for x in xs if cond]. But a transforming ternary goes before the for: [a if cond else b for x in xs]. That ternary is the one-line a if cond else b that reshapes each item. Same keyword, two entirely different jobs. After the for, the if decides which items get in. Before it, sitting inside the result slot, the if/else decides what shape each kept item takes. The tell is the else. A filter never has one, because there is no "else, keep it out" — the item simply isn't produced. A transforming ternary always does.

TYPE THIS · save it, then run itpipeline.py — raw strings in, a readable report out, one comprehension per stage
# pipeline.py -- raw text in, a printable report out, one comprehension per stage

RAW = [
    "Blinding Lights|The Weeknd|200",
    "Titanium|David Guetta ft. Sia|245",
    "  Save Your Tears|The Weeknd|215  ",
    "One More Time|Daft Punk|320",
    "",
    "Strobe|deadmau5|607",
    "bad guy|Billie Eilish|194",
]

# STAGE 1 -- clean: strip the whitespace, drop the blank rows
rows = [line.strip() for line in RAW if line.strip()]
print("1 clean     :", len(rows), "rows kept of", len(RAW))

# STAGE 2 -- parse: one string becomes one dict (a lazy split feeds the unpack)
songs = [
    {"title": t, "artist": a, "seconds": int(secs)}
    for t, a, secs in (row.split("|") for row in rows)
]
print("2 parse     :", songs[0])

# STAGE 3 -- filter: only tracks over 3:30 clear the gate
longs = [s for s in songs if s["seconds"] > 210]
print("3 gate      :", [s["title"] for s in longs])

# STAGE 4 -- format: reshape each survivor into one printable line
def clock(seconds):
    """245 -> '4:05'."""
    return f"{seconds // 60}:{seconds % 60:02d}"

lines = [f"{s['title']:<16}{clock(s['seconds']):>6}  {s['artist']}" for s in longs]
print("4 format    :", len(lines), "lines built")
print()
print("\n".join(lines))
print()
print("runtime kept:", sum(s["seconds"] for s in longs), "of",
      sum(s["seconds"] for s in songs), "seconds")
print("RAW is untouched:", RAW[2])
1 clean     : 6 rows kept of 7
2 parse     : {'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': 200}
3 gate      : ['Titanium', 'Save Your Tears', 'One More Time', 'Strobe']
4 format    : 4 lines built

Titanium          4:05  David Guetta ft. Sia
Save Your Tears   3:35  The Weeknd
One More Time     5:20  Daft Punk
Strobe           10:07  deadmau5

runtime kept: 1387 of 1781 seconds
RAW is untouched:   Save Your Tears|The Weeknd|215  
Save it as pipeline.py and run it. This is the shape almost every real data job takes: text arrives dirty, and four small steps turn it into something a human can read. Each stage is one comprehension, and each one hands a whole new list to the next. Stage 1 does two jobs at once. The gate if line.strip() throws the blank row away, and the expression line.strip() cleans the survivors. Notice the cost hiding there: strip() runs twice per line, once in the gate and once in the result. That is the price of the one-liner, and it is usually worth paying. Stage 2 is the one to sit with. The round brackets inside are a generator expression, splitting one row at a time, and the outer square brackets pull on it. Three parts come out of each split and unpack straight into t, a, secs. Stage 3 is a pure gate, and stage 4 a pure reshape, calling a small clock() function you wrote with chapter 9's tools. Two things to try. Change the gate in stage 3 to > 600 and watch every later line follow, because none of them is typed — each is derived from the one above. Then print RAW at the end. Six comprehensions ran and not one of them touched your original data.
FILTER AFTER, TRANSFORM BEFORE
[x for x in xs if cond] — no else, this is the gate, it lives at the end. [a if cond else b for x in xs] — has an else, this is a reshape, it lives up front. Put a bare filter before the for and Python raises a SyntaxError; put a ternary after it and you'll silently filter on the wrong thing.
playlist.pypython
# playlist: five dicts, each with title / artist / seconds
# "Blinding Lights" 200 · "Titanium" 245 · "Save Your Tears" 215
# "One More Time" 320 · "Strobe" 607

# FILTER — the if goes AFTER the for: keep only the long ones
long_titles = [s["title"] for s in playlist if s["seconds"] > 300]
# ['One More Time', 'Strobe']

# TRANSFORM — the ternary goes BEFORE the for: reshape every song
tags = ["long" if s["seconds"] > 300 else "short" for s in playlist]
# ['short', 'short', 'short', 'long', 'long']

# swap the brackets, swap the container — same three slots
durations = {s["title"]: s["seconds"] for s in playlist}   # dict comp
artists   = {s["artist"] for s in playlist}                # set comp
print(len(artists))   # 4 — The Weeknd appears twice but a set keeps one

The last block is the quietly brilliant part: the brackets on the outside pick the container, and swapping them costs you nothing. Square brackets build a list. Curly braces build a dict when the expression is a key: value pair, or a set when it is a lone value. And a set, being a set, silently drops repeats. That is exactly why pulling artist out of five songs hands you back four unique names, not five. Slide the gate below and watch a filtering comprehension rebuild its list in real time.

Drag the gate — watch the new list rebuild▸ drag me
[ s["title"] for s in playlist if s["seconds"] > 300 ] playlist — five songs Blinding Lights 200 s Titanium 245 s Save Your Tears 215 s One More Time 320 s Strobe 607 s gate → the new list "One More Time", "Strobe", kept 2 of 5
300 s
Raise the threshold and songs drop out of the gate one by one; the new list on the right rebuilds from whatever survives. At 0 everything passes; past 607 the list is empty. The gate never searches — it just tests each song once, in order.
Fig 2 — The trailing if is a gate every item is walked past exactly once. Lit (green) songs cleared it and landed in the new list; the rest were never produced.
One loop body, four wrappersfigure
the same loop body — the outer brackets alone pick the container [ … ] list ordered · keeps duplicates { … : … } dict key → value pairs · unique keys { … } set unique items · unordered ( … ) generator nothing built yet — the next section →
Fig 3 — Change the wrapper, change the result type — the three slots inside never move. Round brackets are the odd one out: they build nothing up front, which is the whole point of the section after this.
SYNTAX · the same fold, in braceseight shapes — the colon that picks a dict, and the one-line flip
{key: value for x in xs} — DICT: braces with a colon {key: value for x in xs if cond} — same three slots, gate at the end {k: v for k, v in d.items()} — walk a dict you already have {v: k for k, v in d.items()} — THE FLIP: invert a dict in one line {expr for x in xs} — SET: braces, a lone value, no colon {expr for x in xs if cond} — repeats collapse, order is not kept {} — an empty DICT. There is no empty-set literal set() — this is how you write an empty set
the colonThe whole difference. A colon in the result slot builds a dict; a lone value builds a set.
the flip{v: k for k, v in d.items()} files the same data the other way round. One line, and one quiet cost below.
collisionsDuplicate keys never raise. The last pair written wins, and the earlier one is gone without a word.
hashabilityKeys must be hashable, so a flip only works while the values are. Lists as values give you TypeError.
set orderA set comp answers in the set's own order, which is not yours. Wrap it in sorted() before a human reads it.
you type
playlist = [
    {"title": "Blinding Lights", "artist": "The Weeknd",   "seconds": 200},
    {"title": "Titanium",        "artist": "David Guetta", "seconds": 245},
    {"title": "Save Your Tears", "artist": "The Weeknd",   "seconds": 215},
    {"title": "One More Time",   "artist": "Daft Punk",    "seconds": 320},
    {"title": "Strobe",          "artist": "deadmau5",     "seconds": 607},
]

# DICT COMP -- the colon is the whole difference
durations = {s["title"]: s["seconds"] for s in playlist}
print(durations["Strobe"], "|", len(durations), "entries")

# duplicate keys do not error. The LAST one silently wins.
by_artist = {s["artist"]: s["title"] for s in playlist}
print(by_artist)

# SET COMP -- a lone value, no colon. Repeats collapse.
artists = {s["artist"] for s in playlist}
print(len(artists), sorted(artists))

# the same three slots: gate at the end, expression up front
minutes = {t: secs // 60 for t, secs in durations.items() if secs > 210}
print(minutes)

# THE FLIP -- swap the halves of every pair, in one line
title_of = {secs: t for t, secs in durations.items()}
print(title_of[607], "|", title_of[200])

print(type({}).__name__, type({1}).__name__, type(set()).__name__)

try:
    {[1, 2]: "nope" for _ in range(1)}
except TypeError as e:
    print("TypeError:", e)
you see
607 | 5 entries
{'The Weeknd': 'Save Your Tears', 'David Guetta': 'Titanium', 'Daft Punk': 'One More Time', 'deadmau5': 'Strobe'}
4 ['Daft Punk', 'David Guetta', 'The Weeknd', 'deadmau5']
{'Titanium': 4, 'Save Your Tears': 3, 'One More Time': 5, 'Strobe': 10}
Strobe | Blinding Lights
dict set set
TypeError: unhashable type: 'list'
where beginners trip
  • {} is an empty dict, never an empty set. The braces were spoken for by dicts years before sets arrived.
  • A dict comp over duplicate keys silently shrinks your data. Five songs by four artists gives four entries, and Python says nothing.
  • Forget the colon and you get a set of keys instead of a mapping. Both are legal, so nothing warns you.
  • Inverting needs hashable values. {v: k for k, v in playlists.items()} over list values raises TypeError: unhashable type.
  • A set comp is not a tuple comp. Round brackets build the lazy thing in section 04, and there is no tuple comprehension at all.
Wait — square brackets build a list, curly braces build a dict or a set. So what on earth do you get if you write the very same comprehension inside round brackets? Not a tuple — Python already spends that syntax elsewhere. You get something that hasn't computed a single item yet.

There is one property of every comprehension you have written so far that is easy to miss precisely because it is invisible: a comprehension is eager. The instant the line finishes running, the entire container exists in memory, every element computed and stored, all at once. For five songs that is nothing. But ask for [n*n for n in range(1_000_000)] and Python obediently parks about 8 megabytes of finished list in RAM before you so much as glance at the first element. The round-bracket version from the curio weighs roughly 200 bytes, because it has built nothing. Hold that gap. It is the hinge to the next section.

NOW WRITE IT YOURSELFflip a dict in one line — then count what the flip quietly ate
Chapter 7 had you invert a dict with a for loop and setdefault. Do it again in one line, and then go looking for what the line costs. Start with lengths = {"Blinding Lights": 200, "Titanium": 245, "Save Your Tears": 215, "Levels": 203}. Write by_length as a single dict comprehension that swaps each pair, so that by_length[245] hands you 'Titanium'. Now break it on purpose. Add lengths["One Dance"] = 200, a second song of exactly 200 seconds, and run the same line again. Print len(lengths) and len(by_length) side by side before you look at the values. Five went in and four came out, and nothing raised. Work out which title survived and why, from the reading order alone. Then repair it, still as a comprehension: build {secs: [titles that share secs] for secs in set(lengths.values())} so no song is eaten. Read your repair honestly afterwards. The inner comprehension re-walks the whole dict for every distinct length, so it does n × m work where chapter 7's loop did n. Write that loop version too and compare the two with ==. Last, ask what the flip assumes: try inverting {"gym": ["Titanium", "Levels"]} and read the TypeError out loud.
show the solution
lengths = {"Blinding Lights": 200, "Titanium": 245,
           "Save Your Tears": 215, "Levels": 203}

# 1 -- THE ONE-LINER: swap the two halves of every pair
by_length = {secs: title for title, secs in lengths.items()}
print(by_length)
print(by_length[245])

# 2 -- THE COLLISION: add a second 200-second song and run it again
lengths["One Dance"] = 200
naive = {secs: title for title, secs in lengths.items()}
print(len(lengths), "songs in ->", len(naive), "keys out")
print(naive[200])

# 3 -- THE HONEST FLIP, still a comprehension: each key keeps a LIST
grouped = {secs: [t for t, s in lengths.items() if s == secs]
           for secs in set(lengths.values())}
for secs in sorted(grouped):
    print(secs, "->", grouped[secs])

# 4 -- the same grouping in one pass, because the comprehension above is n squared
one_pass = {}
for title, secs in lengths.items():
    one_pass.setdefault(secs, []).append(title)
print(one_pass == grouped)

# 5 -- and the flip only works while the values are hashable
playlists = {"gym": ["Titanium", "Levels"], "focus": ["Strobe"]}
try:
    {v: k for k, v in playlists.items()}
except TypeError as e:
    print("TypeError:", e)

# ------------- what it prints -------------
{200: 'Blinding Lights', 245: 'Titanium', 215: 'Save Your Tears', 203: 'Levels'}
Titanium
5 songs in -> 4 keys out
One Dance
200 -> ['Blinding Lights', 'One Dance']
203 -> ['Levels']
215 -> ['Save Your Tears']
245 -> ['Titanium']
True
TypeError: unhashable type: 'list'
↺ reframe
Stop reading a comprehension as a compressed loop and start reading it as a sentence about a set. [t.upper() for t in titles if len(t) > 6] is just "the upper-cased title, for every title, where the title is longer than six characters." The brackets aren't punctuation you tolerate — they're the verb that says build me the collection this sentence describes.

✗ The myth

A comprehension is just a show-off way to write a for loop — fewer keystrokes, same machine underneath, and probably a touch slower for being clever.

✓ The reality

It compiles to different, leaner bytecode than a hand-written append loop — a dedicated list-building opcode, no repeated .append lookup — so it usually runs a little faster, not slower. And it's an expression: it evaluates to a value you can drop straight into a function call, and its loop variable never escapes into the surrounding code.

The deeper cut — "a loop in one line" is a labelled simplification

Calling a comprehension "a for loop written on one line" is a useful lie: true enough to start with, and worth refining exactly once. Two real differences hide underneath. First, scope. In Python 3 a comprehension runs in its own little frame, so its loop variable does not leak out. Run a plain for x in range(3): pass and afterwards x is still 2. Run [x for x in range(3)] and afterwards x is a NameError, because the name never existed outside the brackets. That isolation is a feature: comprehensions can't quietly clobber a variable you were using.

Second, a comprehension is an expression that produces a value, where a loop is a statement that produces nothing. That's why you can nest a comprehension inside a function argument but never a for block. You can also stack the slots. Multiple for clauses read outer-to-inner and flatten, so [(a,b) for a in xs for b in ys] walks every pair. And multiple if clauses simply and together. This is powerful. But past two clauses a real loop is usually kinder to the next reader, and that judgement is the actual skill.

A comprehension of a million items builds all million before you touch the first — and throws the whole thing away if you only needed one. What if the sequence could stay a promise, handing you elements one at a time, only ever computing the next when you ask? →

NOW WRITE IT YOURSELFthree loops, and only two of them should become comprehensions
Three loops. Two of them want to be comprehensions and one of them does not, and telling those apart is the actual skill. Use the five-song playlist of dicts from this section. Drill 1: a loop that opens an empty list and appends s["title"].upper() for every song. Fold it into one line, then prove the two results are equal with == rather than by eye. Drill 2: a loop that keeps only songs over 210 seconds and appends a formatted string like 'Titanium (4:05)'. Fold that one too, and put the gate in the right place the first time. Drill 3 is the one to think about. A loop that walks the playlist, continues past anything over 600 seconds, adds the rest to a running total, and prints a line each time. Try to fold it, then stop and write down why you should not. Three tells: it builds no list, it does two things per item, and it needs continue. If you force it, you get [print(...) for s in playlist], which builds a list of None that nobody wants and quietly allocates one slot per song. A comprehension is for making a container. When you want effects instead, the loop was never the ugly option.
show the solution
playlist = [
    {"title": "Blinding Lights", "artist": "The Weeknd",           "seconds": 200},
    {"title": "Titanium",        "artist": "David Guetta ft. Sia", "seconds": 245},
    {"title": "Save Your Tears", "artist": "The Weeknd",           "seconds": 215},
    {"title": "One More Time",   "artist": "Daft Punk",            "seconds": 320},
    {"title": "Strobe",          "artist": "deadmau5",             "seconds": 607},
]

# DRILL 1 -- a pure transform. Folds cleanly.
loop_1 = []
for s in playlist:
    loop_1.append(s["title"].upper())
comp_1 = [s["title"].upper() for s in playlist]
print("1 loop:", loop_1)
print("1 comp:", comp_1, "| same:", loop_1 == comp_1)

# DRILL 2 -- a gate and a transform. Gate goes AFTER the for.
loop_2 = []
for s in playlist:
    if s["seconds"] > 210:
        loop_2.append(f"{s['title']} ({s['seconds'] // 60}:{s['seconds'] % 60:02d})")
comp_2 = [f"{s['title']} ({s['seconds'] // 60}:{s['seconds'] % 60:02d})"
          for s in playlist if s["seconds"] > 210]
print("2 loop:", loop_2)
print("2 comp:", comp_2, "| same:", loop_2 == comp_2)

# DRILL 3 -- this one STAYS a loop. It builds no list; it does things.
running = 0
skipped = []
for s in playlist:
    if s["seconds"] > 600:
        skipped.append(s["title"])
        continue
    running += s["seconds"]
    print(f"   queued {s['title']:<16} running total {running:>4}s")
print("3 total  :", running, "| skipped:", skipped)

# the "clever" comprehension that does the same thing -- and why it is wrong
sink = [print("   NEVER DO THIS", s["title"]) for s in playlist[:2]]
print("3 sink   :", sink)

# ------------- what it prints -------------
1 loop: ['BLINDING LIGHTS', 'TITANIUM', 'SAVE YOUR TEARS', 'ONE MORE TIME', 'STROBE']
1 comp: ['BLINDING LIGHTS', 'TITANIUM', 'SAVE YOUR TEARS', 'ONE MORE TIME', 'STROBE'] | same: True
2 loop: ['Titanium (4:05)', 'Save Your Tears (3:35)', 'One More Time (5:20)', 'Strobe (10:07)']
2 comp: ['Titanium (4:05)', 'Save Your Tears (3:35)', 'One More Time (5:20)', 'Strobe (10:07)'] | same: True
   queued Blinding Lights  running total  200s
   queued Titanium         running total  445s
   queued Save Your Tears  running total  660s
   queued One More Time    running total  980s
3 total  : 980 | skipped: ['Strobe']
   NEVER DO THIS Blinding Lights
   NEVER DO THIS Titanium
3 sink   : [None, None]
Wait — {} is an empty dict, never an empty set. The curly braces were spoken for by dicts long before sets arrived, so there's no empty-set literal at all — you have to write set(). A set comprehension like {s["artist"] for s in playlist} is fine because it's visibly non-empty; but the moment you want zero items, {} quietly hands you the wrong container.
STACKED FORS READ TOP-DOWN
Two loops in one comprehension keep the exact order you'd write them stacked: [x for row in grid for x in row] means 'for row in grid: then for x in row:' — outer first, left to right — and flattens [[1,2],[3,4]] to [1, 2, 3, 4]. Swap the two for clauses and row isn't defined yet: NameError. The reading order is the running order.
DICT COMPS OVERWRITE, SETS DROP
Feed duplicate keys to a dict comprehension and the last one wins: {s["artist"]: s["title"] for s in playlist} keeps one entry per artist, and The Weeknd's later song silently overwrites the earlier. A set comp {s["artist"] for s in playlist} collapses the same repeats to a single name. Same curly braces, two different rules for a collision.
Wait — what if the gate and the result both need the same computed value? The walrus := makes it once and hands it to both: [y for s in playlist if (y := s["seconds"] // 60) >= 4] divides by 60 a single time per song, tests y in the gate, and emits that very y — no double work. Added in Python 3.8 for exactly this: bind inside an expression, reuse it a step later.

04Generators: sequences that don't exist yet

Every collection you've met so far is already there. A list of song lengths is a million boxes filled and sitting in RAM before you read a single one. And a song's length is only its human label. Drop one altitude and Blinding Lights is its actual bytes: a run of roughly 8.8 million signed 16-bit samples, each one a speaker-cone position. A generator flips that on its head. It's a function that says yield where an ordinary function would say return, and that one word changes what "calling" even means. Call it and none of the body runs. Not the first line, not the loop, nothing. What you get back isn't a value. It's a generator object holding one thing: a suspended frame. A running function always has a frame, its private scratch space of local variables plus a pointer to the exact line it is on. Chapter 9 stacked one such frame on every call. A generator simply freezes that frame instead of discarding it when the function pauses: the frozen locals, and a bookmark on the line to resume at, parked at the top and waiting.

The values arrive one at a time, only when something asks. A for loop asks. next() asks. sum() asks. Each request wakes the body and runs it forward until it evaluates the next yield expression. Then it hands over that single value and freezes on the spot, every local variable still standing exactly where it was. Ask again and it thaws from that same line, not from the beginning. This is lazy evaluation: nothing is computed until the instant it's needed. The body advances by exactly one yield per knock. However many statements sit between two yields, one next() runs precisely that far and no further. The sequence doesn't exist in memory. It exists as a promise to produce the next item when you knock.

SYNTAX · yield — a def that pauses instead of finishingsix shapes — the word that freezes a frame, and the exception that ends every loop
def name(args): — one yield anywhere makes the WHOLE def a generator body yield value — hand one value out, then FREEZE on this line more body — runs on the NEXT knock, not on this one gen = name(args) — calling it runs nothing. You hold a paused frame next(gen) — thaw, run to the next yield, hand it over, freeze next(gen, default) — the same, but hands back default instead of raising for v in name(args): — the loop calls next() and catches the ending for you list(name(args)) sum(name(args)) — drain it in one go, into a container or a fold
what changesOne yield anywhere in the body changes what calling means. You get a generator object, and zero body lines run.
the freezeIt stops at the yield, not after it. The locals stay warm and a bookmark holds the exact line.
one knock, one yieldHowever many statements sit between two yields, one next() runs exactly that far and stops.
the endingFalling off the end raises StopIteration. That exception is how every for loop in Python knows to stop.
returnA bare return ends the generator early. Its value never reaches a for loop; it rides inside the exception.
you type
def countdown(n):
    print(f"   [body woke up, n={n}]")
    while n > 0:
        yield n
        n -= 1
    print("   [body fell off the end]")

gen = countdown(3)
print(type(gen).__name__, "- called, and not one body line has run")

print(next(gen))
print(next(gen))
print(next(gen))

try:
    next(gen)
except StopIteration:
    print("StopIteration - this generator is empty forever")

print(next(gen, "nothing left"))

print(list(countdown(2)))
print(sum(countdown(4)))

again = countdown(2)
print("first pass :", list(again))
print("second pass:", list(again))

def only_two():
    yield "Titanium"
    return "this value never reaches a for loop"
    yield "unreachable"

print(list(only_two()))
you see
generator - called, and not one body line has run
   [body woke up, n=3]
3
2
1
   [body fell off the end]
StopIteration - this generator is empty forever
nothing left
   [body woke up, n=2]
   [body fell off the end]
[2, 1]
   [body woke up, n=4]
   [body fell off the end]
10
   [body woke up, n=2]
   [body fell off the end]
first pass : [2, 1]
second pass: []
['Titanium']
where beginners trip
  • Calling it runs no code. Put a print on the first line and you will not see it until the first next().
  • Mixing yield and a value-returning return confuses everyone, including you in a month. Pick one.
  • It is one-shot. After the first for loop drains it, a second loop over the same object finds nothing.
  • No len(gen), no gen[0], no rewind. The only way to see a value is to consume it.
  • Bare next(gen) on an empty generator raises. next(gen, None) hands back the default instead, which is the polite knock.
THE ONE IDEA TO CARRY FORWARD
A list stores its values; a generator stores the instructions for making them, and a bookmark saying how far it's gotten. That's the whole difference — the recipe versus the meal — and every other property of generators falls out of it: their tiny fixed size, their one-shot nature, their ability to stream data far larger than your RAM.

Remember the round brackets from the last section? (expr for x in xs) is a generator expression, a comprehension that builds nothing. Swap a list comprehension's square brackets for round ones and the memory story changes from brutal to free. A list of a million squares materialises a million boxed int objects plus a million-slot pointer array. On this machine that's ≈ 40 MB, every byte of it paid up front, before you so much as glance at the first square. The generator version is a single small object — exactly 200 bytes on this Python — and it stays 200 bytes whether it will eventually yield ten values or ten trillion. It stores the recipe, so its size has nothing to do with how much meal it can cook.

SYNTAX · the generator expressionseven shapes — round brackets, and the pair you are allowed to leave out
(expr for x in xs) — round brackets: a generator. It builds nothing (expr for x in xs if cond) — same three slots as a list comp, same order sum(expr for x in xs) — sole argument: the call's brackets do double duty sum((expr for x in xs), start) — a second argument means you must bracket it yourself max(expr for x in xs) — any(...) all(...) min(...) join(...) all take one list(expr for x in xs) — cash it in. Identical result to [expr for x in xs] next(gen) — or pull exactly one value and leave the rest
the bracketsRound brackets are the only change from a list comp. They cost you nothing up front and give you laziness.
not a tupleThere is no tuple comprehension. Python spent round brackets on this instead, which is the better trade.
the sizeA generator expression weighs a flat 200 bytes, whether it will yield ten values or ten trillion.
naked in a callWhen the generator is the only argument, drop its brackets. Add a second argument and they come back.
versus yieldSame machine, shorter spelling. Use a genexp for one expression; write a def with yield when you need statements.
you type
seconds = [200, 245, 215, 320, 607]

gen = (s // 60 for s in seconds)
print(type(gen).__name__, "- round brackets, nothing computed")
print(next(gen), next(gen))
print(list(gen), "- only what was still ahead of the bookmark")
print(list(gen), "- one-shot, and it is spent")

print(sum(s for s in seconds if s > 210))
print(max(s for s in seconds), min(s for s in seconds))
print(any(s > 600 for s in seconds), all(s > 100 for s in seconds))
print(" ".join(str(s // 60) for s in seconds))
print(sum((s for s in seconds), 1000))

titles = ["Blinding Lights", "Titanium", "Strobe"]
print(sorted((t.lower() for t in titles), key=len))

print(type((s for s in seconds)).__name__, type([s for s in seconds]).__name__)
print(type((200, 245, 607)).__name__)

try:
    eval("sum(s for s in seconds, 0)")
except SyntaxError as e:
    print("SyntaxError:", e.msg)
you see
generator - round brackets, nothing computed
3 4
[3, 5, 10] - only what was still ahead of the bookmark
[] - one-shot, and it is spent
1387
607 200
True True
3 4 3 5 10
2587
['strobe', 'titanium', 'blinding lights']
generator list
tuple
SyntaxError: Generator expression must be parenthesized
where beginners trip
  • It is one-shot, like everything lazy. sum(gen) twice gives you the total, then 0.
  • sum(x for x in xs, 0) is a SyntaxError: Generator expression must be parenthesized. Two arguments, two sets of brackets.
  • Printing one shows <generator object <genexpr> at 0x...>. Nothing is wrong; nothing has been asked for.
  • Building it can never raise on your data, because the body has not run. The error surfaces at the first next().
  • If you need the values twice, build the list. Laziness that you consume twice is just a bug with good intentions.
lazy.pypython
def song_lengths(songs):
    for song in songs:
        yield song["seconds"]

gen = song_lengths(playlist)   # nothing runs yet — you hold a paused recipe
print(next(gen))   # 200  — runs to the first yield, then freezes
print(next(gen))   # 245  — wakes exactly where it paused

squares_list = [x*x for x in range(1_000_000)]   # ~40 MB, built NOW
squares_gen  = (x*x for x in range(1_000_000))   # 200 bytes, builds nothing
print(sum(squares_gen))   # 333332833333500000 — one square at a time
Same million squares — one built, one merely promisedfigure
[x*x for x in range(1_000_000)] eager — every element built NOW … × 1,000,000 a million boxed ints + a million pointers ≈ 40 MB paid in full before you read square #1 (x*x for x in range(1_000_000)) lazy — a recipe, not the meal code + a bookmark frozen at yield 0 next(g) → 0, 1, 4, 9, … one at a time its size never grows with n = 200 B the same 200 B for a trillion values
Fig 04 — Same million squares. The list materialises every int now (≈40 MB); the generator holds a paused recipe (exactly 200 B) and cooks one value per knock. Nothing on the right side exists until you ask for it.
TYPE THIS · save it, then run itmemory_honest.py — a million squares on the scales, list against generator
# memory_honest.py -- the same million squares, weighed two ways

import sys

N = 1_000_000

# THE EAGER WAY -- every square exists before you read the first one
squares_list = [n * n for n in range(N)]
print("list      :", type(squares_list).__name__)
print("  len                :", len(squares_list))
print("  getsizeof           :", sys.getsizeof(squares_list), "bytes")
print("  sum                 :", sum(squares_list))

# THE LAZY WAY -- nothing exists yet; only the recipe does
squares_gen = (n * n for n in range(N))
print("generator :", type(squares_gen).__name__)
print("  getsizeof           :", sys.getsizeof(squares_gen), "bytes")
print("  sum                 :", sum(squares_gen))

# getsizeof weighs ONE object, never the web of objects it points into
one_int = squares_list[-1]
print()
print("  one int object      :", sys.getsizeof(one_int), "bytes")
print("  pointer array alone :", sys.getsizeof(squares_list), "bytes")
print("  honest total (approx):",
      sys.getsizeof(squares_list) + N * sys.getsizeof(one_int), "bytes")

# and the two objects behave differently on a second reading
print()
print("  sum the list again  :", sum(squares_list))
print("  sum the gen again   :", sum(squares_gen))

# the same fold with no name and no list -- the shape you will actually write
print()
print("  straight through    :", sum(n * n for n in range(N)))
print("  peak sample         :", max(abs(s) for s in (0, 12000, -22000, 3000)))
list      : list
  len                : 1000000
  getsizeof           : 8448728 bytes
  sum                 : 333332833333500000
generator : generator
  getsizeof           : 200 bytes
  sum                 : 333332833333500000

  one int object      : 32 bytes
  pointer array alone : 8448728 bytes
  honest total (approx): 40448728 bytes

  sum the list again  : 333332833333500000
  sum the gen again   : 0

  straight through    : 333332833333500000
  peak sample         : 22000
Save it as memory_honest.py and run it. The numbers are from this machine, on CPython 3.12, and yours will land within a few bytes. Both halves compute the same total, 333332833333500000, and that is the point: the answer is identical, the bill is not. The list reports 8,448,728 bytes and the generator reports 200. Then read the honest part underneath. getsizeof weighs one object, and a list's own body is only its array of pointers. Each of the million pointers aims at a separate int worth 32 bytes more, which is how 8 MB becomes about 40 MB in reality. The generator has no such web to pay for. The last three lines are the ones to keep. Summing the list again gives the same total, because the values are still sitting there. Summing the generator again gives 0, because the bookmark is parked at the end and there is nothing left to walk. And sum(n * n for n in range(N)) gets the answer with no name, no list, and no second reading — the shape you will actually write. Two things to try. Change N to ten million and watch which line makes your fan spin. Then swap the comprehension for list(range(N)) and notice getsizeof drop to 8000056: a comprehension cannot know the final length, so it over-allocates as it grows. Chapter 11 opens this hood properly.

To feel the freeze-and-resume, drive it by hand. Below, each nudge of the slider is one next(gen) on our four-song playlist. Watch the bookmark walk down to the yield, hand you a value, and stop, locals still warm. Then it picks up from the very same line on the next call. Push it one step past the end and you'll meet the signal that ends every loop.

One next() at a time — watch the bookmark freeze at yield▸ drag me
each next(gen) resumes the body, runs to the next yield, hands back one value, then freezes def song_lengths(songs): for song in songs: yield song["seconds"] next() calls 0 local song → hands back → Fresh generator — called, but zero lines have run yet. You hold a recipe. the playlist the bookmark is walking — each slot lights as it's yielded: Blinding Lights 200 Titanium 245 Levels 203 One More Time 320
not called yet
Steps 1–4 each pause at the same yield line with song still set — proof the function is asleep, not gone. Step 5 asks once too often: the loop is out of songs, so Python raises StopIteration — the "I'm empty" signal every for loop quietly listens for.
Fig 04b — A generator is a function on pause. The bookmark parks at yield, locals frozen; the next call resumes from that exact spot. One knock, one value — never the whole sequence at once.
↺ it's a paused function, not a compressed list
The instinct is to picture a generator as a list squeezed smaller — same values, less space. It isn't. Nothing was compressed because nothing was built. You're holding a function with a bookmark in it, and every next() cooks exactly one serving on the spot and hands that out — but the pan stays on the stove. The frozen frame (its locals, its half-finished loop) is precisely what is kept between calls; only the one yielded value leaves the kitchen. Throw the pan away and there'd be nothing left to resume. The "sequence" is a story the code tells one word at a time — it has no more physical existence than the next sentence of a book you haven't read yet.

Now slide the count of items to a million and watch who pays. The list's cost climbs in a dead-straight line; the generator's never twitches off its 200-byte floor.

Slide n toward a million — the list bill grows, the generator's doesn't▸ drag me
list [ ] ≈ 3.8 MB gen ( ) = 200 B at 100,000 items the list costs ≈ 20,000× the generator estimate: ~40 B per list item (8-B pointer + one boxed int).The generator is one fixed frame — measured at exactly 200 B here — and never grows.
100,000 items
The red bar's length is n — double the items, double the bill, all paid before the first read. The green sliver is laziness: a flat line at 200 bytes no matter how far you push the slider.
Fig 04c — Eager cost scales with the data; lazy cost is constant. That flat green line is the entire reason generators exist.
AN HONEST 40 MB
Ask Python sys.getsizeof(squares_list) and it reports only ≈ 8 MB — that's the pointer array alone, the list's own body. The number is telling the truth but not the whole truth: each of the million pointers aims at a separate int object worth ~32 bytes more, and those are what push the real bill to ≈ 40 MB. getsizeof measures one object, never the web it points into. The generator has no such web — hence a flat 200.
A generator is single-use — it empties as you read it
The bookmark only moves forward, and there's no rewind. Build lengths = (s["seconds"] for s in playlist), then sum(lengths) gives 968 — but a second sum(lengths) gives 0, because the bookmark is already parked at the end. The same trap bites map, filter, and zip, which are generators in disguise. Need the data twice? Pour it into a list once and reuse that, or rebuild the generator from scratch.
THE SAME GENERATOR, ONE ALTITUDE DOWN
Fold the playlist's durations and you get its runtime; fold a song's samples and you get its loudness — the same lazy pattern, one layer of metal deeper. Take the opening of a track as a concrete run: samples = array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000]). Pour it into max exactly like the durations go into summax(abs(s) for s in samples)24000, the peak — and the values stream through one at a time and vanish. Over all 8.8 million samples of the real track the generator ever holds one, never the waveform: a duration is a label you could write on a card, but the samples are the bytes the sound actually is.
The one-liner that retires reduce
sum(s["seconds"] for s in playlist)968. A generator expression handed straight to a function needs no brackets of its own — the function's parentheses do double duty. No lambda, no import, no intermediate list ever built: the values stream through sum and vanish. This pattern — a lazy stream poured into sum, max, any, "".join — quietly replaces most of what people once reached for functools.reduce to do.
Wait — if next() only ever runs "up to the next yield," what runs the code after the last one? When does that inner for loop actually finish? It finishes on the call that finds no more yields: the body resumes past the final value, the loop runs dry, the function falls off its end — and that "fell off the end" is reported not as a return value but as a raised StopIteration. There is no quiet last value; there's a value, then later an exception. That exception is how iteration ends everywhere in Python.

✗ The myth

Laziness is just a memory-saving trick — a weaker list you tolerate to keep RAM down.

✓ The reality

Laziness lets you express things an eager list cannot: an endless stream of values, or a sequence far larger than your memory. Stream the raw samples of a track the same way the playlist holds its songs — Blinding Lights is an array('h') of about 8.8 million signed 16-bit samples (-32768..32767, 0 = cone at rest), a packed ≈17 MB that still must sit whole in RAM. Feed the file through a generator instead and it yields one 2-byte sample at a time, folding them to a single peak — or an RMS loudness — as they pass; the track can be 17 MB or 17 GB and the generator stays ~200 bytes, because not one full copy of the waveform ever exists. os.walk() is a generator for the same reason: it crawls an entire drive one folder at a time without ever holding the whole file tree. Not weaker — strictly more expressive.

The deeper cut — iterators, StopIteration, and why range plays by different rules

Under every for loop is a two-step ritual. Python calls iter() on your object to get an iterator. Then it calls next() on that iterator over and over until it raises StopIteration, which the loop catches silently and treats as "stop." A generator is an iterator: iter(gen) is gen returns True, so it is its own bookmark. That's precisely why generators are one-shot. An iterator has a single position, and once it reaches the end there's nowhere left to go.

So why can you loop over range(1_000_000) twice, and index it with r[3], when a generator forbids both? Because range is lazy without being an iterator. It's a lazy sequence. It weighs a flat 48 bytes and computes any element on demand, but it holds no bookmark of its own. Each for loop asks it for a fresh iterator, so you can walk it again and again. Lazy and reusable are different powers. A generator trades reusability away in exchange for being able to run arbitrary code between values.

One honesty note on that "200 bytes, stores nothing" line: it's a labelled simplification. A generator object does store something. It holds a small, fixed-size execution frame: its paused locals and the exact instruction to resume at. What it doesn't do is copy the data it walks over. If your generator loops across a giant list, that list still occupies memory. The generator just borrows a reference to it. "Constant size" means constant overhead, not literally zero. The win is that the overhead never scales with how many values flow through.

Lazy or eager, a playlist eventually wants to be in ORDER — and Python ships two ways to sort it that look almost identical yet behave in opposite ways. One returns a new list; the other quietly rewrites yours and hands back nothing. →

NOW WRITE IT YOURSELFwrite first_n_evens — then take the stopping condition away
Write first_n_evens(n): a generator that hands back the first n even whole numbers, starting at 0. Two rules. It must use yield, and it must never build a list inside itself. Check it three ways. list(first_n_evens(5)) should give [0, 2, 4, 6, 8]; sum(first_n_evens(5)) should give 20; and calling next() one time too many should raise StopIteration rather than returning anything. Catch that exception and print your own line, so you have seen it happen on purpose once. Now take the stopping condition away. Write evens() with while True and no counter at all, and take the first five with itertools.islice(evens(), 5). This is the thing a list cannot do: the recipe has no length, only the meal does. Weigh it with sys.getsizeof(evens()) and you will get a small fixed number near 184, a little under the 200 a generator expression costs, because its frame holds different locals. Two last checks. Write the same job as a one-line generator expression and decide which spelling you would ship. Then walk one generator twice and watch the second walk come back empty.
show the solution
import sys
from itertools import islice

# 1 -- the bounded generator: n evens, then it stops on its own
def first_n_evens(n):
    """Yield the first n even whole numbers, starting at 0."""
    count = 0
    value = 0
    while count < n:
        yield value
        value += 2
        count += 1

print(list(first_n_evens(5)))
print(sum(first_n_evens(5)))

gen = first_n_evens(3)
print(next(gen), next(gen), next(gen))
try:
    next(gen)
except StopIteration:
    print("StopIteration - it ran out, exactly on schedule")

# 2 -- the endless one: no stopping condition at all
def evens():
    value = 0
    while True:
        yield value
        value += 2

print(list(islice(evens(), 5)))
print(sys.getsizeof(evens()), "bytes -- and it promises every even number there is")

# 3 -- the same job as a generator expression, when the rule is simple enough
print(list(islice((n for n in range(0, 1_000_000) if n % 2 == 0), 5)))

# 4 -- one-shot, so a second walk finds nothing
g = first_n_evens(4)
print("first walk :", list(g))
print("second walk:", list(g))

# ------------- what it prints -------------
[0, 2, 4, 6, 8]
20
0 2 4
StopIteration - it ran out, exactly on schedule
[0, 2, 4, 6, 8]
184 bytes -- and it promises every even number there is
[0, 2, 4, 6, 8]
first walk : [0, 2, 4, 6]
second walk: []
EVERY OPEN FILE IS A GENERATOR
for line in open("huge.log"): doesn't load the file — the file object is a lazy iterator that hands you one line at a time and forgets it. That's how you can scan a 50 GB log on an 8 GB laptop: at no instant is more than a single line in memory. Laziness isn't a party trick here — it's the only reason the line above doesn't fall over. Fold a 17 GB lossless recording down to its single loudest sample the same way — max(abs(s) for s in samples(f)) — and the peak drops out with never more than one 2-byte sample resident.
Drag the stream past your RAM — watch which reader dies▸ drag me
The same file, two ways to read it — where does eager die? 8 GB — all this laptop has eager · list(f) ≈ 3.2 GB in RAM lazy · for line in f 120 B 0.37× your RAM list(f) still fits — drag right until it crosses the 8 GB wall. streamed, the same job holds one line (120 B) — 26,000,000× smaller. order-of-magnitude: list(f) must hold ≈ the whole file; one line ≈ 120 B; RAM taken as 8 GB.
3.2 GB stream
Slide right and the eager bar grows toward the dashed RAM wall — the instant the stream tops 8 GB it can't hold the file, so it slams the wall and dies as a MemoryError. The lazy reader never moves off 120 bytes: one line in, one line out. That flat green sliver is why for line in open("huge.log") scans a stream far bigger than the machine it runs on.
Fig 04d — Laziness isn't just cheaper — it lifts a hard ceiling. Eager reading caps at your RAM and then crashes; a generator's only limit is . (Sizes are order-of-magnitude, not a benchmark.)
Wait — a generator can be endless. A function that does n = 0; while True: yield n; n += 1 promises every whole number forever — and still weighs one small fixed frame. You can't build an infinite list (it would eat all the RAM on Earth), but you can hold an infinite promise and take only the first five with itertools.islice. The recipe has no length; only the meal does.
↺ it's the engine under every loop
Generators aren't a niche tool you'll rarely touch — they're the machinery under all iteration. for x in anything secretly calls iter() then next() until StopIteration; a, b, c = trio pulls the same lever; so do print(*items) and sum(xs). Learn how a generator freezes and resumes and you haven't learned one feature — you've learned how Python walks a sequence, everywhere.
PEEK SAFELY WITH next()
A generator has no gen[0], no len(gen), no rewind — the only way to see a value is to consume it. Bare next(gen) on an empty one raises StopIteration and bites; but next(gen, None) takes an optional default and hands back None instead of crashing. It's the difference between knocking and knocking politely.
Trace3 step the machine — idea · code · memory move together
A generator is a suspended frame — calling it runs nothing
Calling song_lengths(playlist) executes zero lines of the body. Python builds a frozen frame — its locals plus a bookmark parked at the top — and hands it back. Each next() resumes that frame, runs to the next yield, hands out one value, and re-freezes right there. Knock once too often and the body falls off its end as a raised StopIteration. Press Next.
fixed ~200 B4 valuesthen StopIteration
the suspended frame — a paused function living on the heap~200 B
song_lengths() · frozen at top · 0 lines run
def song_lengths(songs):
for song in songs:
yield song["seconds"]
⊣ body ends → raises StopIteration
songsplaylist ↗
song
next(gen) hands back →
Blinding Lights200
Titanium245
Levels203
One More Time320
In plain words
Under the hood
CALLALU · the operation
Driver · lazy.py
Memory · the generator object & its frozen frame
gengenerator0x1f04 → frame
resume atlinetop (not started)
values yieldedcount0
frame stateflagGEN_CREATED
frame size — fixed, never grows with how many values flow
~200 B
what's happening
beat 1 / 1

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

Two ways to put your playlist in order, spelled almost identically, and they do opposite things to your data. Learn the difference once and you've learned a rule that runs through the whole language, far past sorting. A dot-method reaches into the object and edits that very object, then hands you back None. A wrapping function reads the object, leaves it untouched, and hands you back a brand-new value. Say it out loud with the playlist in hand. playlist.sort() grabs the actual list and reorders the references right where they sit. It never builds a second list object, so the old order is gone for good. (Not literally zero extra memory: the sort borrows a small temporary buffer, up to about half the list, to merge through, but nothing the size of a whole second list.) sorted(playlist) does build that second list. It makes a fresh copy, arranges it, and gives it to you. Your original never so much as twitches.

There's a second split hiding in the punctuation. .sort() is a method, so it lives on lists, and on nothing else. Hand it a tuple or a set and Python shrugs: 'tuple' object has no attribute 'sort'. But sorted() is a free-standing function that will happily chew through any iterable — a tuple, a set, a dict, even a one-shot generator that hasn't produced its values yet. And it always answers with a list. One is a specialist bolted to one type. The other is a generalist that meets your data wherever it lives.

THE ONE RULE TO CARRY FORWARD
When the name has a dot in front of it and changes the thing — list.sort, list.append, list.reverse — assume it mutates in place and returns None. When the name wraps the thing — sorted(x), reversed(x) — assume it leaves the original alone and builds something new. This one distinction will save you from a whole genus of bug for the rest of your Python life.

Both accept the same steering wheel, key=: you hand it a function reference, the name, no parentheses. Each item is then judged not by itself but by whatever that function returns for it — a stand-in, a proxy value. Want the playlist ordered by length? Use key=lambda s: s["seconds"], and every song is compared by its number of seconds instead of by the whole dict. That's chapter 9's first-class functions cashing in: a function you can pass around like any other value. Add reverse=True and the same ordering runs longest-first. Miss the "reference, not call" part and you get a face-full of error. key=len() tries to run len right now, with no argument, and dies. key=len passes the function along to be called later, once per item.

SYNTAX · sorted, .sort, and the key= sloteight shapes — one copies, one rewrites, and both take the same steering wheel
sorted(iterable) — a NEW list. Your original is untouched sorted(iterable, key=fn) — judge each item by fn(item), not by itself sorted(iterable, key=fn, reverse=True) — same order, run backwards, ties still stable sorted(xs, key=lambda s: (a, b)) — a tuple key: sort on a, break ties on b lst.sort() — IN PLACE. Rewrites your list, hands back None lst.sort(key=fn, reverse=True) — same steering wheel, still returns None max(iterable, key=fn) — one extreme in one pass. Do not sort for this min(iterable, key=fn) — same again, the other end
the functionsorted() eats any iterable — tuple, set, dict, even a one-shot generator — and always answers with a list.
the method.sort() exists on lists and nothing else. It reorders in place and returns None, on purpose.
key=A function reference, called once per item. Each item is compared by what that function returns for it.
tuple keysReturn a tuple to sort on two fields. Python compares left to right, so the first field wins unless it ties.
stabilityEqual keys never trade places. That is a written guarantee, and it is what lets you sort twice to sort by two things.
you type
playlist = [
    {"title": "Blinding Lights", "artist": "The Weeknd",   "seconds": 200},
    {"title": "Titanium",        "artist": "David Guetta", "seconds": 245},
    {"title": "Save Your Tears", "artist": "The Weeknd",   "seconds": 215},
    {"title": "One More Time",   "artist": "Daft Punk",    "seconds": 320},
    {"title": "Strobe",          "artist": "deadmau5",     "seconds": 607},
]
def names(rows):
    return [s["title"] for s in rows]

# the FUNCTION: builds a new list, leaves yours alone
by_len = sorted(playlist, key=lambda s: s["seconds"])
print(names(by_len))
print(names(playlist))

longest_first = sorted(playlist, key=lambda s: s["seconds"], reverse=True)
print(names(longest_first))

# a TUPLE key sorts on two fields at once, left field first
by_artist = sorted(playlist, key=lambda s: (s["artist"], s["seconds"]))
print(names(by_artist))

# sorted() eats any iterable, even a generator, and always returns a list
print(sorted((s["title"] for s in playlist), key=len))

# one extreme is not a sorting job
print(max(playlist, key=lambda s: s["seconds"])["title"],
      "|", min(playlist, key=lambda s: s["seconds"])["title"])

# key runs once per item, not once per comparison
calls = []
def watched(song):
    calls.append(song["title"])
    return song["seconds"]

sorted(playlist, key=watched)
print(len(playlist), "songs ->", len(calls), "key calls")

# the METHOD: rewrites your list, hands back None
result = playlist.sort(key=lambda s: s["seconds"])
print(result, "|", names(playlist))

try:
    sorted(playlist, key=len())
except TypeError as e:
    print("TypeError:", e)
you see
['Blinding Lights', 'Save Your Tears', 'Titanium', 'One More Time', 'Strobe']
['Blinding Lights', 'Titanium', 'Save Your Tears', 'One More Time', 'Strobe']
['Strobe', 'One More Time', 'Titanium', 'Save Your Tears', 'Blinding Lights']
['One More Time', 'Titanium', 'Blinding Lights', 'Save Your Tears', 'Strobe']
['Strobe', 'Titanium', 'One More Time', 'Blinding Lights', 'Save Your Tears']
Strobe | Blinding Lights
5 songs -> 5 key calls
None | ['Blinding Lights', 'Save Your Tears', 'Titanium', 'One More Time', 'Strobe']
TypeError: len() takes exactly one argument (0 given)
where beginners trip
  • songs = songs.sort() destroys your list. The method returns None, so songs is now None.
  • key=len() raises TypeError: len() takes exactly one argument. Pass the name, key=len, and let sorting call it.
  • .sort() is a list method. A tuple or a set gives you AttributeError; reach for sorted() instead.
  • sorted(some_dict) sorts the keys, because looping a dict yields keys. Use key=d.get to judge by value.
  • Sorting to find one extreme is n log n work for an n-work question. max(xs, key=fn) walks it once.
  • Python 3 refuses to compare a str with an int. A crash you can see beats an order you cannot trust.
sorting.pypython
# the function: reads the playlist, builds a NEW list, original untouched
by_length = sorted(playlist, key=lambda s: s["seconds"])
print(by_length[0]["title"])    # Old Town Road — shortest first
print(playlist[0]["title"])     # Blinding Lights — the original never moved

# the method: reorders the actual list, hands back nothing
result = playlist.sort(key=lambda s: s["seconds"], reverse=True)
print(result)                   # None — .sort() returns None, always
print(playlist[0]["title"])     # One More Time — the list ITSELF was reordered
Two verbs, two fates for your listfigure
playlist.sort(key=…) a method on the list → edits that very object playlist reordered IN PLACE returns: None sorted(playlist, key=…) a function → builds a NEW list playlist untouched ✓ new list sorted copy returns: the new list
Fig 05a — Same job, opposite mechanics. The dot-method rearranges the object you already have and returns None; the function copies, sorts the copy, and leaves your original exactly where it was.

Reading about it is one thing; watching your list's fate flip under your hand is another. Below is one playlist of four songs, drawn as bars — taller is longer. Slide through the four calls you might write. Watch two questions answer themselves at once: did the original playlist change? and what did the call hand back? Each run starts from the same fresh playlist, so you can compare them cleanly.

Slide through four calls — watch the list and the return value▸ drag me
you wrote: playlist.sort() the playlist object — the list you started with 200s 245s 320s 113s Blinding Titanium OneMore OldTown mutated — reordered in place the value the call hands back to you None None — the sort was a side effect
.sort() — method
Each bar is a song, keyed by length: OneMore 320s, Titanium 245s, Blinding 200s, OldTown 113s. The two .sort() calls leave None below and a shuffled playlist above; the two sorted() calls leave the playlist untouched and grow a fresh list below.
Fig 05b — The method mutates the top row and returns None; the function keeps the top row frozen and returns the new bottom row. reverse=True only flips the direction, never the rule.

✗ The myth

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

✓ The reality

.sort() reorders in place and returns None. That "tidy one-liner" throws away your list and binds songs to None — your data is one keystroke from gone, and the next line that indexes songs crashes. Write songs.sort() on its own line, or songs = sorted(songs). Never both halves at once.

Wait — if returning the list would let me chain calls so conveniently, why did Python's designers deliberately make .sort() return None? Precisely to stop the bug above. Returning None is a loud signal — "I changed your object; I did not make you a copy, so there's nothing meaningful to hand back." It's a house rule across the language: methods that mutate in place (.sort, .append, .reverse, .extend) all answer None, on purpose, so you can never mistake a mutation for a fresh value.
↺ it was never about sorting
You didn't learn a sorting fact — you learned to read Python's grammar. Verbs that change the thing tend to be dot-methods that return None: lst.append(x), lst.reverse(), lst.sort(). Verbs that hand you a new thing tend to be wrapping functions that leave the original alone: sorted(lst), reversed(lst), list(lst). Sorting is just the most famous member of a whole family. Once you feel the pattern, you can often guess whether a call copies or mutates before you've read a line of its docs.
A TRADE-OFF, NOT A WINNER
.sort() builds no second list — it rearranges the references already in place, borrowing only a small temporary merge buffer (up to about half the list). sorted() allocates a whole new list of the same length: a real, measurable cost. A list of a million integers weighs about 8 MB just for its slots (sys.getsizeof reports 8000056 bytes), and sorted() briefly holds a second one alongside the first. Working with big data and truly done with the old order? .sort(). Everything else — anytime someone else might still need the original — reach for sorted(), the version that can't surprise you.
KEY IS A REFERENCE, AND IT CAN BE A TUPLE
Pass the function's name: key=len, not key=len() — the second one runs len immediately with no argument and errors before sorting even starts. And a key can return a tuple to sort on several fields at once: key=lambda s: (s["seconds"], s["title"]) orders by length, then breaks ties alphabetically by title. Python compares tuples left to right, so the first field wins unless it's equal.
stable.pypython
# key returns a PROXY: sort by whole minutes, not exact seconds
for s in sorted(playlist, key=lambda s: s["seconds"] // 60):
    print(s["title"], s["seconds"] // 60)
# Blinding Lights 3   ← both land in minute-bucket 3,
# Levels 3            ← and keep their ORIGINAL order (stable sort)
# Titanium 4
Wait — when two songs tie on the key, which comes first? The one that was already first. Python's sort is stable: equal keys never trade places, they keep their input order. That's not a lucky accident you can lean on by chance — it's a written guarantee, and it's what lets you sort by one field, then another, and have the first sort's order survive inside each group of the second.
The deeper cut — Timsort, why the key runs only once per item, and how reverse stays honest

Under both sorted() and .sort() is the exact same engine: Timsort. Tim Peters invented it for Python in 2002, and Java and others have since borrowed it. It's an adaptive, stable sort running in O(n log n). It hunts for already-ordered runs in your data and merges them, so a nearly-sorted playlist sorts almost for free.

Two subtleties worth pocketing. First, your key function is called exactly once per element, not once per comparison. Python computes every key up front, sorts using those cached proxies, then throws them away. That's the classic decorate–sort–undecorate. Sort eight items and a counting key fires eight times, full stop, even though the comparisons number in the dozens. So an expensive key, like a database hit or a heavy computation, costs you n calls, not n log n. That's cheap enough to stop worrying about.

Second, reverse=True does not sort ascending and then flip the list. If it did, tied items would come out backwards. Instead Timsort reverses the sense of the comparison while preserving stability, so two songs of equal length keep their original order even in a descending sort. Same guarantee, both directions.

The mirror image lives one verb over. reversed(order) is a function. It leaves order untouched and hands back a new reversed iterator. order.reverse() is a method. It flips the list in place and returns None. That's exactly the same split as sorted versus .sort, which is the whole point. It was never a one-off rule about ordering. It's the shape of the language.

ERROR CLINICyou will meet these — decoded
  File "C:\tmp\gate.py", line 2
    longs = [t.upper() if len(t) > 6 for t in titles]
             ^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: expected 'else' after 'if' expression
You meant a gate — keep only the long titles — but you parked the if before the for, and in the result slot an if can only be a ternary, so Python is really demanding the else a ternary must carry — not offering the filter you were trying to write.
the gate goes after the for — [t.upper() for t in titles if len(t) > 6]; an if before the for must bring its else
Traceback (most recent call last):
  File "C:\tmp\peek.py", line 2, in <module>
    first = lengths[0]
            ~~~~~~~^^^
TypeError: 'generator' object is not subscriptable
There is no slot 0 to point at. A generator stores no items, so square brackets find nothing to index — it is really saying you asked a machine for a position when all it has is a next: streams answer to pulling, never to pointing.
next(gen) for one value — or list(gen) once, if the job truly needs positions
Traceback (most recent call last):
  File "C:\tmp\ranked.py", line 3, in <module>
    print(songs[0])
          ~~~~~^^^
TypeError: 'NoneType' object is not subscriptable
The crash line is innocent. The wound is one line up: songs = songs.sort() reordered the list in place, handed back None, and the assignment bound your name to it — the message can only name the symptom it sees, really a mutation mistaken for a value, caught one line too late.
mutate on its own line — songs.sort() — or copy with songs = sorted(songs); never both halves at once
TYPE THIS · the chapter capstonetop_tracks.py — a generator fold, two comprehensions and one sorted(key=), in 40 lines
# top_tracks.py -- one small report, built entirely out of this chapter

playlist = [
    {"title": "Blinding Lights", "artist": "The Weeknd",           "seconds": 200, "plays": 4120},
    {"title": "Titanium",        "artist": "David Guetta ft. Sia", "seconds": 245, "plays": 2870},
    {"title": "Save Your Tears", "artist": "The Weeknd",           "seconds": 215, "plays": 3990},
    {"title": "One More Time",   "artist": "Daft Punk",            "seconds": 320, "plays": 1510},
    {"title": "Strobe",          "artist": "deadmau5",             "seconds": 607, "plays":  640},
    {"title": "Levels",          "artist": "Avicii",               "seconds": 203, "plays": 2210},
]

def clock(seconds):
    """203 -> '3:23'."""
    return f"{seconds // 60}:{seconds % 60:02d}"

# 1 -- a generator fold: the whole runtime, and no list is ever built
total = sum(s["seconds"] for s in playlist)

# 2 -- a comprehension gate: the tracks people actually play
popular = [s for s in playlist if s["plays"] >= 2000]

# 3 -- sorted with a tuple key: most played first, ties broken by title
ranked = sorted(popular, key=lambda s: (-s["plays"], s["title"]))

# 4 -- a set comp feeding a dict comp: each artist's longest track
longest = {
    artist: max((s for s in playlist if s["artist"] == artist),
                key=lambda s: s["seconds"])["title"]
    for artist in {s["artist"] for s in playlist}
}

print(f"TOP TRACKS   {len(playlist)} songs, {clock(total)} of music")
print("-" * 48)
for rank, s in enumerate(ranked, start=1):
    print(f"{rank}. {s['title']:<16}{s['plays']:>6} plays   {clock(s['seconds'])}")
print("-" * 48)
print("in this table :", sum(s["plays"] for s in ranked), "plays,",
      sum(s["seconds"] for s in ranked), "seconds")
print("share of runtime:", f"{sum(s['seconds'] for s in ranked) / total:.0%}")
print("cut for low plays:", [s["title"] for s in playlist if s["plays"] < 2000])
print()
for artist in sorted(longest):
    print(f"  {artist:<22} longest: {longest[artist]}")
print()
print("playlist order is untouched:", [s["title"] for s in playlist][:3], "...")
TOP TRACKS   6 songs, 29:50 of music
------------------------------------------------
1. Blinding Lights   4120 plays   3:20
2. Save Your Tears   3990 plays   3:35
3. Titanium          2870 plays   4:05
4. Levels            2210 plays   3:23
------------------------------------------------
in this table : 13190 plays, 863 seconds
share of runtime: 48%
cut for low plays: ['One More Time', 'Strobe']

  Avicii                 longest: Levels
  Daft Punk              longest: One More Time
  David Guetta ft. Sia   longest: Titanium
  The Weeknd             longest: Save Your Tears
  deadmau5               longest: Strobe

playlist order is untouched: ['Blinding Lights', 'Titanium', 'Save Your Tears'] ...
Save it as top_tracks.py and run it. Every tool this chapter owns is doing one job here, and nothing else is. The runtime comes from a generator fold: sum(s["seconds"] for s in playlist) streams six numbers through sum and builds no list at all. The shortlist is a comprehension gate, one line for what would be four. The ranking is sorted with a tuple key, and the minus sign in -s["plays"] is the small honest trick: negate the number and ascending order becomes most-played-first, with s["title"] breaking ties alphabetically. Then the lookup table stacks three things at once. A set comprehension collects the distinct artists, a dict comprehension walks them, and inside each one max takes a generator of that artist's songs with a lambda key. Read that line twice; it is the densest in the chapter, and every piece of it is something you met on its own. One number is worth pausing on: the four ranked tracks are 48% of the runtime and carry 13190 of the plays. Their 863 seconds is the same total section 02 folded with reduce, arrived at from the other direction. Two things to try. Drop the plays threshold to 1500 and watch one row appear without any other line changing. Then delete the - from the key and read the table upside down, which is the fastest way to feel what a tuple key really does.

You just measured a sorted copy costing eight million bytes while the original sat right beside it. But where does that number come from — what does a list, an int, a whole object actually weigh, and how do you make it lighter? Next chapter opens the hood: weighing anything with getsizeof, slimming objects with __slots__, and caches that trade memory for speed. Memory, mastered →

SAY IT BACKthe chapter in five breaths
  1. map and filter hand back machines, not results — 48-byte IOUs that compute one item per pull, weigh the same over four songs or four million, and empty for good after a single pass.
  2. reduce is one accumulator walking the stream once — no seed means the first item starts it; pass the operation's identity so even an empty stream answers honestly — and the everyday folds already have sharper names: sum, max, any, "".join.
  3. A comprehension is three slots — result · for · gate — and the outer brackets alone pick the container: square builds the whole list now, round builds nothing at all.
  4. A generator is a function frozen at yield — locals warm, a bookmark on the line — advancing exactly one yield per knock, ending as a raised StopIteration, its frame a small fixed ~200 bytes whether it promises ten values or ten trillion.
  5. The grammar splits clean: a dot-method like .sort() rewrites the object and returns None; a wrapping function like sorted() leaves it alone and hands back a brand-new list.
You already owned the pieces: chapter 9 made functions into values you can hand around — every fn, pred and key= here is that idea cashing in — chapter 6 built the lists these belts stream from, and chapter 0 switched on the bytes behind every sample. This chapter added one question and kept asking it: when does the work actually happen?
Wait — sorted(durations) on a dict doesn't error and doesn't sort the values — it hands back a sorted list of the keys: ['Blinding Lights', 'Levels', 'Titanium']. That's because looping a dict yields its keys, and sorted chews any iterable. Want to order by value instead? sorted(durations, key=durations.get) — sort the keys, but judge each by what it maps to.
DON'T SORT TO FIND ONE EXTREME
The longest track isn't a sorting job: max(playlist, key=lambda s: s["seconds"]) walks the list once — O(n) — and takes the exact same key= steering wheel as sorted. Sorting first and grabbing [-1] does n log n work for an n-work question. min, max, and sorted are one family — reach for the smallest that answers.
Wait — the sort behind sorted() has a name and a birthday: Timsort, written for Python by Tim Peters in 2002. It worked so well the rest of the industry took it — it's now the default object sort in Java, on Android, and inside Chrome's V8 engine. Every time your playlist orders itself, it runs an algorithm the whole world borrowed back from Python.
PYTHON 3 WON'T SORT APPLES AND ORANGES
sorted([245, "Titanium"]) raises TypeError: '<' not supported between instances of 'str' and 'int'. Python 2 would have quietly ordered them by type name — a meaningless result that hid real bugs. Python 3 refuses to guess: if two things can't be compared with <, it stops and says so. A crash you can see beats an order you can't trust.
Trace3 step the machine — idea · code · memory move together
Decorate, sort, undecorate — key() runs once and ties never swap
sorted() computes each song's key exactly once into a cached proxy, orders by those proxies, keeps equal‑key items in their original order (stable), then discards the proxies — leaving the original list untouched. Press Next and watch the tags pin on, the bars slide, and the tags peel away.
key · once/itemsort · stablereturns a new list
The idea — tag once, sort by tags, peellist · 3 songsstart
original playlistnever moves
equal → keep input order
key calls: 0 / 3
In plain words
Under the hood
KEYALU · the operation
Program · sort_playlist.py
variables · type
Memory · proxy cache + two lists
registers — the running state
proxy cache key(item) — computed once, in input order
original playlistuntouched
new sorted listnot built
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 10, in working code

A generator is a function that pauses. It hands back one value at a yield, freezes its whole frame, and thaws again on the next next() — so you can stream a million samples through a program that never holds more than one.

First light — yield
A function with a yield stops being an ordinary function. Calling it runs no body at all — it hands back a generator object. Only next() drives the body forward, one yield at a time.
Lazy by design
Generators compute nothing until asked. A generator expression(x*x for x in ...) — is a generator with no def, and a reducer like sum can drain a whole stream into one number.
Streams without end
Because a generator only ever holds one value, it can be infinite. You take what you need — a break, an islice, a running peak — and simply stop pulling.
Pipelines
Because a generator both consumes and produces one item at a time, you can chain them. Each value trickles through the whole pipeline before the next one is ever fetched.
Frames that pause and frames that nest
A generator's superpower is a suspended frame it can return to. Ordinary calls stack frames too — and while a callee runs, the caller's own locals are out of reach.
end of chapter 10 · five sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked