python/volume-1/chapter-10ch 10 / 64 · 17 min

10Conveyors & generators

In Chapter 9 we turned functions into values you can pass around. Now we put that to work: 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, and reduce a column of runtimes down to a single total. Then we write that same work as one readable comprehension instead of a hand-cranked loop. All the way through, we keep asking the one question that matters — when does the work really happen? — because the answer is almost never the moment you'd guess. That question leads straight into generators: 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, it's clear 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 two plain lines.

map and filter: two machines on a conveyor reduce: fold many values into one Comprehensions: map + filter in one readable line Generators: sequences that don't exist yet
scroll to begin

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. The mental model is to picture them bolted over a conveyor belt: items roll past, a machine acts on each one, and a new stream flows out the far end. map(fn, stream) takes a function reference — the bare name, no parentheses, Chapter 9's rule — and runs every item that rolls past through it, emitting the reshaped stream. filter(pred, stream) takes a predicate, which is just a function that answers True or False about one item, and 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 that 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.
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 one only at the instant it's asked for. That sounds like a mere quirk until you meet the job that needs it: a stream you could never hold all at once, like 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 handles both with ease. That gap is the felt need this whole chapter is built to answer.

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 timeinteractive
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 has one more trick. 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 streams of mismatched lengths never throws. 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 falsy0, 0.0, "", None, and any empty container ([], {}) — and 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.

↺ 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 better.

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. The total runtime of a playlist. The longest track. A merged index. This works one altitude down at the metal, too. A three-minute track is a stream 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. It works by carrying a single running result called the accumulator. Your function takes exactly two parameters — the accumulator always first, 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 timeinteractive
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]), where that third argument is 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(...), and 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 examples earn it. The first is 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. The second is 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.
Trace3 step the machine — idea · code · memory move together
The snowball — one accumulator, one pass, 0 → 863
reduce carries a single accumulator left to right, absorbing one song's seconds per step. Press Next and watch the snowball roll — the last value standing is the playlist's total runtime.
4 songsseed 0Σ = 863 s
The snowball — acc rolls left → right, absorbing one songint · one accumulatorseed
acc0
In plain words
Under the hood
SEEDALU · combine acc + sec
Program · playlist_runtime.py
variables · type
Memory · registers + accumulator
registers — the running state
acc int · the one value kept alive across items
what's happening
beat 1 / 1

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: 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 — map (turn each item into something else) and filter (throw some items away) — into a single expression that evaluates to a finished container.

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, just reshaped by the expression. That is the whole grammar. Everything else is a variation 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.

Two placements trip up everyone, and they are worth learning 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 — the one-line a if cond else b that reshapes each item — goes before the for: [a if cond else b for x in xs]. 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 — there is no "else, keep it out", the item simply isn't produced — but a transforming ternary always does.

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's 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 rebuildinteractive
[ 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.
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 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 on to that gap. It's what leads into the next section.

↺ 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 helpful simplification — 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 ([(a,b) for a in xs for b in ys] walks every pair), and multiple if clauses simply and together. That's powerful. But past two clauses a real loop is usually kinder to the next reader, and knowing when to stop 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? →

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.
Trace3 step the machine — idea · code · memory move together
Eager build — the gate tests, the expression reshapes, the list grows now
A list comprehension visits each song once, tests the trailing gate, and only for survivors runs the front expression and appends. Press Next and watch the ledger walk, the gate reject or admit, and the result list assemble in real time.
5 songsgate >300seager
The ledger — visit · gate · reshapelist · builds nowbuild
visitgate · secs>300.upper()
result · long0
eager · built as we go
In plain words
Under the hood
BUILDALU · the operation
Program · playlist.py
variables · type
Memory · registers + result
registers — the running state
long list · eager — fully built when the line ends
what's happening
filter AFTER · transform BEFORE · container built immediately
beat 1 / 1

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, sit parked at the top and wait.

The values arrive one at a time, only when something asks. A for loop asks. next() asks. sum() asks. Each request wakes the body, runs it forward until it evaluates the next yield expression, hands over that single value, and then freezes on the spot, with 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.

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.

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.

To see the freeze-and-resume for yourself, 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, with the locals still in place. 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 yieldinteractive
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 straight line. The generator's cost never moves off its 200-byte floor.

Slide n toward a million — the list bill grows, the generator's doesn'tinteractive
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 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 — a small, fixed-size execution frame, holding 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. →

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 diesinteractive
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

There are two ways to put your playlist in order. They're 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. (This isn't 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: it lives on lists, and on nothing else. Hand it a tuple or a set and Python refuses: 'tuple' object has no attribute 'sort'. But sorted() is a free-standing function that will happily work 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 a single 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 control, key=: you hand it a function reference, the name with 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? Pass 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 an error: key=len() tries to run len right now, with no argument, and dies, while key=len passes the function along to be called later, once per item.

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 it happen under your hand is another. Below is one playlist of four songs, drawn as bars, where 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 valueinteractive
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, invented for Python by Tim Peters in 2002 and since borrowed by Java and others. It's an adaptive, stable sort running in O(n log n) that hunts for already-ordered runs in your data and merges them, so a nearly-sorted playlist sorts almost for free.

Two subtleties are worth keeping. 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. This is 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 (a database hit, a heavy computation) costs you n calls, not n log n — 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.

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 →

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(). That's how 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.com
Written by Ajai Raj