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.
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.
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.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.# 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 offOne 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.
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.<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.map holds no titles at all — only the promise to make them. (The address shown is illustrative.)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 falsy — 0, 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.
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? →
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.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.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, 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.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.
(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.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 → 8630. 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.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.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.+ 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 →
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.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.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.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: 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.
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.
[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: 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 oneThe 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.
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.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.
[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? →
{} 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.[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.{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.:= 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, 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.
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.
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 timeint 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.
next() at a time — watch the bookmark freeze at yieldinteractiveyield 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.yield, locals frozen; the next call resumes from that exact spot. One knock, one value — never the whole sequence at once.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.
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.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.samples = array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000]). Pour it into max exactly like the durations go into sum — max(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.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.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. →
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.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.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.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.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.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.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.
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.
# 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 reorderedNone; 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.
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.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.
.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.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..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=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.# 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 4The 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 →
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.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.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.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.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.
next() drives the body forward, one yield at a time.(x*x for x in ...) — is a generator with no def, and a reducer like sum can drain a whole stream into one number.islice, a running peak — and simply stop pulling.