Session 30 of the Python Fundamentals series, covered in depth. It explains what actually happens when you write a for loop: iter() builds an iterator, next() pulls one value at a time, and a StopIteration signal ends the loop. It then covers generators - functions with yield that produce values lazily, pausing and resuming - along with the compact (x*x for x in it) generator expression, the memory win on large or infinite sequences, and the big trap that a generator is used up after a single pass. The traps covered are that a second loop over a spent generator yields nothing, that calling next past the end raises StopIteration, and that return and yield are easily confused. Every snippet and error message was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 106 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 30
How for really works, and how to hand back values one at a time
Objectives
You have used for since Session 7. Now you will see the machinery under it - and learn to build your own value-producing tools. By the end you can:
for loop uses iter() and next() under the hood.next() raises StopIteration and how for handles it.yield and trace how it pauses and resumes.Warm-up
Discussion prompt
Before we open Session 30 - Iterators & Generators: without looking back, what was the main idea of Session 29 - Lambdas & Functional Tools, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Session 29 of the Python Fundamentals series, in depth. Functions are values you can store and pass around; lambda writes a tiny anonymous function inline; sorted(data, key=...) sorts by anything you compute; and map/filter return lazy iterators you wrap in list().
Section
Part 1
Concept
for x in nums: looks like one thing, but Python does two: it asks the collection for an iterator, then pulls values from it one at a time.
iterator — An object that produces values one at a time and remembers its position. You advance it with next(); it has no length and no going back.
Counterexample
Discussion prompt
for x in nums: looks like one thing, but Python does two: it asks the collection for an iterator, then pulls values from it one at a time.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Concept
iter(collection) hands back an iterator. next(iterator) returns the next value and moves the position forward one step.
A list, string, dict, range, and enumerate can all give you an iterator - they are all iterable.
Analogy
Discussion prompt
Explain iter() gets an iterator; next() pulls a value by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
iter(collection) hands back an iterator. next(iterator) returns the next value and moves the position forward one step.
Ranking
Put in order
Put the moves of iter and next by hand into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. The iterator starts positioned before the first item.
Worked example
nums = [10, 20, 30]
it = iter(nums)
print(next(it))
print(next(it))
print(next(it))iter(nums) builds an iterator over the list
Why: The iterator starts positioned before the first item.
Each next(it) returns one value and advances
Why: First call gives 10, then 20, then 30 - the position walks forward each time.
Read the output
Why: Verified by execution: 10, then 20, then 30.
| call | returns | position now |
|---|---|---|
| iter(nums) | an iterator | before 10 |
| next(it) | 10 | after 10 |
| next(it) | 20 | after 20 |
| next(it) | 30 | after 30 |
Comparison
Comparison matrix
From iter and next by hand: refill the returns column from what you know. The rest of the table is as it appeared.
| call | returns | position now |
|---|---|---|
| iter(nums) | an iterator | before 10 |
| next(it) | 10 | after 10 |
| next(it) | 20 | after 20 |
| next(it) | 30 | after 30 |
Intuition
Picture a bookmark sitting in a book. next() reads the current page and nudges the bookmark forward one page.
The bookmark only moves forward. There is no 'previous page', and it does not know how many pages remain - it just knows where it is right now.
Explain it
Discussion prompt
Explain An iterator is a bookmark to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Picture a bookmark sitting in a book. next() reads the current page and nudges the bookmark forward one page.
Pattern
Predict first
The table runs: iter("hi") | iterator over 'h','i' · next(it) | h
In Strings are iterable too, given the rows so far: what is the next one — the row where call is next(it)?
Correct: next(it) | i
| call | returns |
|---|---|
| iter("hi") | iterator over 'h','i' |
| next(it) | h |
| next(it) | i |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. Strings hand back their characters one at a time, in order.
Worked example
it = iter("hi")
print(next(it))
print(next(it))iter("hi") gives an iterator over the characters
Why: Strings hand back their characters one at a time, in order.
Read the output
Why: Verified by execution: h, then i. Same protocol as the list.
| call | returns |
|---|---|
| iter("hi") | iterator over 'h','i' |
| next(it) | h |
| next(it) | i |
Trade off
Comparison matrix
From Strings are iterable too: every row here is a choice with a cost. Fill the returns column, then say which row you would actually pick and what you give up for it.
| call | returns |
|---|---|
| iter("hi") | iterator over 'h','i' |
| next(it) | h |
| next(it) | i |
Fill the middle
Fill in the blanks
From enumerate hands back an iterator — one line has had its right-hand side removed. Put it back.
letters = ["a", "b"]
it = iter(enumerate(letters))
print(next(it))
print(next(it))
Why: it is what everything below it consumes, so the wrong expression here fails later and somewhere else. Each next(it) yields a (index, value) tuple.
Worked example
letters = ["a", "b"]
it = iter(enumerate(letters))
print(next(it))
print(next(it))enumerate pairs each index with its value
Why: Each next(it) yields a (index, value) tuple.
Read the output
Why: Verified by execution: (0, 'a'), then (1, 'b'). The tools you already use are iterators underneath.
| call | returns |
|---|---|
| next(it) | (0, 'a') |
| next(it) | (1, 'b') |
Error analysis
Annotate
Walk the callouts on enumerate hands back an iterator. Each one is a place this is easy to get subtly wrong.
Worked example
nums = [1, 2, 3]
it = iter(nums)
print(iter(it) is it)Calling iter() on an iterator returns the same object
Why: That is why you can hand an iterator straight to a for loop - it is already its own iterator.
Read the output
Why: Verified by execution: True. An iterable makes fresh iterators; an iterator just returns itself.
| expression | result |
|---|---|
| iter(it) is it | True |
Blank canvas
Draw it
Draw what An iterator's own iter() is itself just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Section
Part 2
Concept
An iterator has no length. When there are no values left, next() does not return anything - it raises the exception StopIteration.
StopIteration — The signal an iterator raises to say 'I am empty'. Calling next() on a used-up iterator raises it.
Definition probe
Sort into buckets
Every line below is part of the definition of iterator or of StopIteration — one or the other, never both. Put each where it belongs.
Faded example
Fill in the blanks
One next() too many, with the scaffolding fading: two lines are gone now — fill both.
nums = [10, 20]
it = iter(nums)
print(next(it))
print(next(it))
print(next(it))
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. The first two calls give 10 and 20; the third finds nothing left.
Worked example
nums = [10, 20]
it = iter(nums)
print(next(it))
print(next(it))
print(next(it))Two values, three next() calls
Why: The first two calls give 10 and 20; the third finds nothing left.
The third call raises
Why: Verified by execution: CPython prints a traceback whose final line reads exactly StopIteration.
| call | result |
|---|---|
| next(it) | 10 |
| next(it) | 20 |
| next(it) | StopIteration (raised) |
Concept
A for loop calls next() again and again, and when StopIteration fires it quietly stops. You never see the exception - the loop just ends.
So for is really a while True that calls next() and breaks on StopIteration.
Step zero
Discussion prompt
Rewrite for by hand — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: Each pass pulls one value
Answer:
Worked example
nums = [1, 2, 3]
it = iter(nums)
while True:
try:
x = next(it)
except StopIteration:
break
print(x)Each pass pulls one value
Why: next(it) fills x with 1, then 2, then 3.
The fourth pull is empty, so we break
Why: next(it) raises StopIteration; the except catches it and breaks.
Trace it pass by pass
Why: Verified by execution: prints 1, 2, 3 - identical to for x in nums. This IS what for does.
| pass | next(it) | prints |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 2 | 2 |
| 3 | 3 | 3 |
| 4 | StopIteration | break |
Pattern
Step through it
Step through Rewrite for by hand one row at a time. What is driving the change, and what would the row after the last one be?
Section
Part 3
Concept
Put the keyword yield in a function and it stops being an ordinary function. It becomes a generator function - a factory for iterators.
generator — An iterator built from a function that uses yield. Each yield hands back one value and pauses the function until the next value is requested.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
Why: These are the working definitions of iterator, StopIteration, generator as Session 30 - Iterators & Generators uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Concept
Calling a generator function does not run its code. It hands back a generator object, frozen at the top, waiting.
The body only advances when you pull a value - with next() or a for loop.
Worked example
def count_up_to(n):
i = 1
while i <= n:
yield i
i += 1
for x in count_up_to(3):
print(x)yield hands back i, then pauses
Why: Each loop pass yields the current i and freezes until the for asks again.
The for loop drives it to the end
Why: When i reaches 4, i <= n is False, the function ends, and the loop stops.
Trace each pass
Why: Verified by execution: prints 1, 2, 3.
| i | i <= n | yields |
|---|---|---|
| 1 | True | 1 |
| 2 | True | 2 |
| 3 | True | 3 |
| 4 | False | stops |
Scale up
Step through it
Step through A count-up generator and watch the numbers move. Now imagine the input ten times bigger: which column is the one that stops this being practical?
Fill the middle
Fill in the blanks
From The generator object, up close — one line has had its right-hand side removed. Put it back.
def count_up_to(n):
i = 1
while i <= n:
yield i
i += 1
g = count_up_to(3)
print(g)
print(next(g))
print(next(g))
Why: g is what everything below it consumes, so the wrong expression here fails later and somewhere else. Nothing in the body has run yet - g is just a paused iterator.
Worked example
def count_up_to(n):
i = 1
while i <= n:
yield i
i += 1
g = count_up_to(3)
print(g)
print(next(g))
print(next(g))count_up_to(3) returns a generator object
Why: Nothing in the body has run yet - g is just a paused iterator.
next(g) drives it to the first yield
Why: You can pull values by hand with next(), exactly like any iterator.
Read the output
Why: Verified by execution: <generator object count_up_to at 0x...> (the address varies), then 1, then 2.
| call | result |
|---|---|
| count_up_to(3) | a generator object |
| next(g) | 1 |
| next(g) | 2 |
Concept
A generator object supports next() and raises StopIteration when it runs out - so it plays by the exact same rules as any iterator from Part 1.
That is why for, list(), sum(), and friends all work on it unchanged. A generator is just an iterator you wrote yourself.
Intuition
return ends a function for good and throws away its local variables. yield is different: it hands back a value but pauses the function, keeping every variable exactly where it was.
Ask for the next value and the function un-pauses, picking up on the line right after the yield. That memory of where it left off is the whole trick.
Section
Part 4
Concept
The first next() runs from the top down to the first yield. The second next() continues from just after that yield to the next one.
Everything between two yields runs on the pull that resumes past the first one - not before.
Step zero
Discussion prompt
Watch it pause and resume — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: gen() runs nothing yet
Answer:
Worked example
def gen():
print("start")
yield 1
print("resumed once")
yield 2
print("resumed twice")
g = gen()
print(next(g))
print(next(g))gen() runs nothing yet
Why: The 'start' line does not print at call time - the body is frozen.
First next(g): top down to yield 1
Why: Prints start, then yields 1 and pauses right there.
Second next(g): resumes after yield 1
Why: Prints 'resumed once', then yields 2 and pauses. 'resumed twice' never runs - we stopped pulling.
Trace the output order
Why: Verified by execution: start, 1, resumed once, 2. Notice 'resumed twice' is absent.
| step | runs | prints |
|---|---|---|
| gen() | nothing yet | - |
| next(g) #1 | start -> yield 1 | start, then 1 |
| next(g) #2 | resumed once -> yield 2 | resumed once, then 2 |
Cost model
Annotate
In Watch it pause and resume, before reading the notes: mark where the time actually goes. Which line dominates?
Worked example
def running_total(nums):
total = 0
for n in nums:
total += n
yield total
for t in running_total([10, 20, 30]):
print(t)total survives across yields
Why: Because the function only pauses, total keeps its value between pulls.
Each pull adds one number and yields the sum so far
Why: This is why generators are handy: they remember state you would otherwise track by hand.
Trace the total
Why: Verified by execution: 10, 30, 60.
| n | total | yields |
|---|---|---|
| 10 | 10 | 10 |
| 20 | 30 | 30 |
| 30 | 60 | 60 |
Pattern
Step through it
Step through A running-total generator one row at a time. What is driving the change, and what would the row after the last one be?
Worked example
def colors():
yield "red"
yield "green"
yield "blue"
for c in colors():
print(c)Three yields, three values
Why: You do not need a loop - each yield is one value, handed back in order.
Read the output
Why: Verified by execution: red, green, blue.
| yield # | value |
|---|---|
| 1 | red |
| 2 | green |
| 3 | blue |
Pattern
Step through it
Step through A generator without a loop one row at a time. What is driving the change, and what would the row after the last one be?
Section
Part 5
Concept
You already know list comprehensions: [x*x for x in nums]. Swap the square brackets for round ones and you get a generator expression: (x*x for x in nums).
Same shape, but it produces values lazily, one at a time, instead of building the whole list up front.
Hypothesis
Predict first
A generator expression in action is about to be worked. State your hypothesis first: which rule or definition decides this one, and what is the first move it forces? Then watch whether the example agrees with you.
Correct: The parentheses make a generator, not a list
Why: Printing it shows a generator object, not [1, 4, 9, 16].
A hypothesis you wrote down is falsifiable; a vague sense of how it will go is not. If the example opens somewhere else, that gap is the thing worth chasing.
Worked example
nums = [1, 2, 3, 4]
squares = (x * x for x in nums)
print(squares)
print(next(squares))
print(next(squares))The parentheses make a generator, not a list
Why: Printing it shows a generator object, not [1, 4, 9, 16].
Pull values with next()
Why: It is an iterator, so next() works just like on any generator.
Read the output
Why: Verified by execution: <generator object <genexpr> at 0x...> (address varies), then 1, then 4.
| call | result |
|---|---|
| (x*x for x in nums) | a generator object |
| next(squares) | 1 |
| next(squares) | 4 |
Concept
[...] is eager: it builds every value now and stores the whole list. (...) is lazy: it holds a recipe and makes each value only when asked.
Reach for a list when you need to keep, index, or reuse the values; reach for a generator expression when you will pass through once.
Ranking
Put in order
Put the moves of Different types, same values into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. The only difference in the source is the bracket, but the types differ.
Worked example
gen = (x * x for x in range(4))
lst = [x * x for x in range(4)]
print(type(gen))
print(type(lst))
print(sum(x * x for x in range(4)))Round brackets give a generator; square give a list
Why: The only difference in the source is the bracket, but the types differ.
A generator expression can feed a function directly
Why: sum() pulls values from the generator - you can even drop the extra parentheses when it is the sole argument.
Read the output
Why: Verified by execution: <class 'generator'>, then <class 'list'>, then 14 (0+1+4+9).
| expression | result |
|---|---|
| type(gen) | <class 'generator'> |
| type(lst) | <class 'list'> |
| sum(x*x for x in range(4)) | 14 |
Worked example
nums = [4, 7, 10, 3, 8]
evens = (n for n in nums if n % 2 == 0)
print(list(evens))The if keeps only some values
Why: Just like a list comprehension, a trailing if filters what gets yielded.
list() pulls every value at once
Why: Wrapping a generator in list() runs it to the end and collects the results.
Trace the filter
Why: Verified by execution: [4, 10, 8].
| n | n % 2 == 0 | kept? |
|---|---|---|
| 4 | True | 4 |
| 7 | False | - |
| 10 | True | 10 |
| 3 | False | - |
| 8 | True | 8 |
Comparison
Comparison matrix
From Filtering in a generator expression: refill the kept? column from what you know. The rest of the table is as it appeared.
| n | n % 2 == 0 | kept? |
|---|---|---|
| 4 | True | 4 |
| 7 | False | - |
| 10 | True | 10 |
| 3 | False | - |
| 8 | True | 8 |
Section
Part 6
Concept
A list holds every value in memory at once. A generator holds only its recipe and its current position - just a value or two at a time.
For a million items that difference is huge, and for values you only pass through once, the list was wasted work.
Step zero
Discussion prompt
Measure the memory gap — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: The list stores a million integers
Answer:
Worked example
import sys
lst = [x for x in range(1000000)]
gen = (x for x in range(1000000))
print(sys.getsizeof(lst))
print(sys.getsizeof(gen))The list stores a million integers
Why: sys.getsizeof reports the object's own size in bytes.
The generator stores almost nothing
Why: It keeps only the recipe and position, no matter how many values it will produce.
Compare the sizes
Why: Verified by execution: 8448728 bytes for the list vs 192 bytes for the generator.
| object | getsizeof (bytes) |
|---|---|
| list of 1,000,000 | 8448728 |
| generator | 192 |
Concept
Because a generator makes values on demand, it can describe a sequence with no end - a while True that keeps yielding. A list of that could never fit in memory.
You control how far it runs by how many values you pull; break out when you have enough.
Fill the middle
Fill in the blanks
From An endless count-up, stopped by break — one line has had its right-hand side removed. Put it back.
def count_from(start):
n = start
while True:
yield n
n += 1
g = count_from(10)
for x in g:
if x > 13:
break
print(x)
Why: g is what everything below it consumes, so the wrong expression here fails later and somewhere else. The generator would yield forever - but it only advances when the loop pulls.
Worked example
def count_from(start):
n = start
while True:
yield n
n += 1
g = count_from(10)
for x in g:
if x > 13:
break
print(x)while True never ends on its own
Why: The generator would yield forever - but it only advances when the loop pulls.
The loop decides when to stop
Why: Once x passes 13 we break, so only four values are ever produced.
Trace each pull
Why: Verified by execution: 10, 11, 12, 13, then break.
| x | x > 13 | action |
|---|---|---|
| 10 | False | print 10 |
| 11 | False | print 11 |
| 12 | False | print 12 |
| 13 | False | print 13 |
| 14 | True | break |
Scale up
Step through it
Step through An endless count-up, stopped by break and watch the numbers move. Now imagine the input ten times bigger: which column is the one that stops this being practical?
Intuition
A list is a warehouse: every item built and stacked before you touch one. A generator is a conveyor belt: it makes the next item only when you reach for it.
The belt never needs room for the whole batch - just the piece in front of you. That is why it can run forever and still fit in memory.
Explain it
Discussion prompt
Explain Conveyor belt, not warehouse to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
A list is a warehouse: every item built and stacked before you touch one. A generator is a conveyor belt: it makes the next item only when you reach for it.
Section
Part 7
Concept
An iterator only moves forward and never resets. Once a generator has yielded its last value, it is exhausted - permanently empty.
Loop over it a second time and you get nothing at all. The bookmark is already at the end.
Analogy
Discussion prompt
Explain A generator is single-use by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
An iterator only moves forward and never resets. Once a generator has yielded its last value, it is exhausted - permanently empty.
Pattern
Predict first
The table runs: first loop | 0, 1, 4 · second loop | (nothing)
In The second loop is empty, given the rows so far: what is the next one — the row where loop is after?
Correct: after | done
| loop | yields |
|---|---|
| first loop | 0, 1, 4 |
| second loop | (nothing) |
| after | done |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. It pulls 0, 1, 4 and leaves the generator at the end.
Worked example
def squares(n):
for i in range(n):
yield i * i
g = squares(3)
print("first loop:")
for x in g:
print(x)
print("second loop:")
for x in g:
print(x)
print("done")The first loop drains g
Why: It pulls 0, 1, 4 and leaves the generator at the end.
The second loop finds nothing
Why: g is exhausted, so the for body never runs - no error, just silence.
Trace both loops
Why: Verified by execution: first loop prints 0, 1, 4; second loop prints nothing; then done.
| loop | yields |
|---|---|
| first loop | 0, 1, 4 |
| second loop | (nothing) |
| after | done |
Pattern
Step through it
Step through The second loop is empty one row at a time. What is driving the change, and what would the row after the last one be?
Step zero
Discussion prompt
list() twice tells the same story — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: The first list() collects everything
Answer:
Worked example
def squares(n):
for i in range(n):
yield i * i
g = squares(3)
print(list(g))
print(list(g))The first list() collects everything
Why: It pulls all values into [0, 1, 4] and empties the generator.
The second list() gets an empty list
Why: There is nothing left to pull, so list() returns [].
Read the output
Why: Verified by execution: [0, 1, 4], then [].
| call | result |
|---|---|
| list(g) first | [0, 1, 4] |
| list(g) again | [] |
Ranking
Put in order
Put the moves of Summing the same generator twice into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. It pulls 0, 1, 2, 3, 4 and leaves g empty.
Worked example
g = (x for x in range(5))
print(sum(g))
print(sum(g))The first sum() consumes all five values
Why: It pulls 0, 1, 2, 3, 4 and leaves g empty.
The second sum() adds nothing
Why: sum() of an empty generator is 0 - a silent, easy-to-miss bug.
Read the output
Why: Verified by execution: 10, then 0.
| call | result |
|---|---|
| sum(g) first | 10 |
| sum(g) again | 0 |
Blank canvas
Draw it
Draw what Summing the same generator twice just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Concept
To iterate again, either call the generator function again for a fresh one, or build a list once and loop over that list as many times as you like.
Choose the list when you truly need the values more than once; otherwise the fresh generator keeps the memory win.
Counterexample
Discussion prompt
To iterate again, either call the generator function again for a fresh one, or build a list once and loop over that list as many times as you like.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
Choose the list when you truly need the values more than once; otherwise the fresh generator keeps the memory win.
Section
Part 8
Anomaly
Predict first
A student writes this, and it looks reasonable:
Storing the generator, then passing over it twice.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: sum works (5), but by then g is exhausted, so max() sees no values and raises ValueError: max() iterable argument is empty.
Build a fresh generator for each pass (or store a list).
Why: sum works (5), but by then g is exhausted, so max() sees no values and raises ValueError: max() iterable argument is empty.
Trap
Storing the generator, then passing over it twice.
def squares(n):
for i in range(n):
yield i * i
g = squares(3)
total = sum(g)
biggest = max(g)sum(g) drains g; max(g) gets an empty generator
Why: sum works (5), but by then g is exhausted, so max() sees no values and raises ValueError: max() iterable argument is empty.
| step | result |
|---|---|
| sum(g) | 5 |
| max(g) | ValueError: max() iterable argument is empty |
Build a fresh generator for each pass (or store a list).
def squares(n):
for i in range(n):
yield i * i
print(sum(squares(3)))
print(max(squares(3)))Each call makes a brand-new generator
Why: sum gets its own generator and max gets another. Real output: 5, then 4. Rule of thumb: one generator, one pass.
| call | result |
|---|---|
| sum(squares(3)) | 5 |
| max(squares(3)) | 4 |
Break the constraint
Discussion prompt
The rule this trap just fixed:
Build a fresh generator for each pass (or store a list).
Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?
Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.
Answer:
sum works (5), but by then g is exhausted, so max() sees no values and raises ValueError: max() iterable argument is empty.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Pulling more values than the generator has.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: After 'a' and 'b' the generator is empty, so next() raises.
Give next() a default, or just use a for loop.
Why: After 'a' and 'b' the generator is empty, so next() raises. CPython prints a traceback ending in StopIteration and the program crashes.
Trap
Pulling more values than the generator has.
def two():
yield "a"
yield "b"
g = two()
print(next(g))
print(next(g))
print(next(g))The third next(g) has nothing to give
Why: After 'a' and 'b' the generator is empty, so next() raises. CPython prints a traceback ending in StopIteration and the program crashes.
| call | result |
|---|---|
| next(g) | a |
| next(g) | b |
| next(g) | StopIteration (raised) |
Give next() a default, or just use a for loop.
def two():
yield "a"
yield "b"
g = two()
print(next(g))
print(next(g))
print(next(g, "done"))next(g, default) returns the default instead of raising
Why: The second argument is a fallback for 'empty'. Real output: a, b, done - no crash. A for loop handles the ending for you automatically.
| call | result |
|---|---|
| next(g) | a |
| next(g) | b |
| next(g, "done") | done |
Error analysis
Annotate
Walk the callouts on Trap: calling next() past the end. Each one is a place this is easy to get subtly wrong.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Trying to produce many values with return.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: With no yield, this is an ordinary function.
yield each value so the function keeps going.
Why: With no yield, this is an ordinary function. return i stops everything and hands back a single value, so you get 0 - not all the evens.
Trap
Trying to produce many values with return.
def get_evens(n):
for i in range(n):
if i % 2 == 0:
return i
print(get_evens(6))return ends the function at the first hit
Why: With no yield, this is an ordinary function. return i stops everything and hands back a single value, so you get 0 - not all the evens.
| you write | result |
|---|---|
| get_evens(6) | 0 (just the first) |
yield each value so the function keeps going.
def get_evens(n):
for i in range(n):
if i % 2 == 0:
yield i
print(list(get_evens(6)))yield i hands back a value and continues the loop
Why: Now it is a generator, so it produces every even. Real output: [0, 2, 4]. yield to stream many values; return to end with one.
| you write | result |
|---|---|
| list(get_evens(6)) | [0, 2, 4] |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
for x in nums: looks like one thing, but Python does two: it asks the collection for an iterator, then pulls values from it one at a time.; iter(collection) hands back an iterator. next(iterator) returns the next value and moves the position forward one step.; Picture a bookmark sitting in a book. next() reads the current page and nudges the bookmark forward one page.Section
Part 9
Pattern
1. iter(iterable) gives you an iterator
Why: Lists, strings, dicts, range, and enumerate are all iterable.
2. next(iterator) returns the next value and advances
Why: It moves forward only; there is no reset and no length.
3. When empty, next() raises StopIteration
Why: That is the signal that there are no more values.
4. for does 1-3 for you
Why: It calls iter once, next repeatedly, and stops on StopIteration - no exception ever surfaces.
Pattern
1. Put yield in a function to make it a generator
Why: Any function with yield returns a generator object instead of running immediately.
2. yield one value at a time; the function pauses there
Why: Local variables survive the pause, so state carries over between pulls.
3. Drive it with a for loop, next(), list(), or sum()
Why: Each of these pulls values until StopIteration.
4. Remember it is single-use
Why: After one full pass it is empty; rebuild it or store a list to go again.
Pattern
Pass through once, or huge/infinite? Use (x for x in it)
Why: Lazy: tiny memory, values made on demand.
Need to keep, index, or reuse the values? Use [x for x in it]
Why: Eager: the whole list stays in memory and can be looped many times.
Feeding a function like sum or max? A generator expression is enough
Why: You can even drop the extra parentheses when it is the only argument.
Real world
Discussion prompt
Outside this lesson: where does Session 30 - Iterators & Generators actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Generator expression or list comprehension? is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 30 of the Python Fundamentals series, in depth. What actually happens when you write a for loop: iter() builds an iterator, next() pulls one value at a time, and a StopIteration signal ends the loop.
Check
Track the position.
it = iter([5, 6, 7])
print(next(it))
print(next(it))| call | returns |
|---|---|
| next(it) | ? |
| next(it) | ? |
Check your understanding
What does this print?
Answer: A
Why: iter() starts before the first item; each next() returns the next value and advances, so you get 5 then 6. Verified by execution.
Check
There are two values.
it = iter([1, 2])
print(next(it))
print(next(it))
print(next(it))| call | result |
|---|---|
| next(it) #3 | ? |
Check your understanding
What happens on the third next(it)?
Answer: A
Why: After 1 and 2 the iterator is empty, so the third next() raises StopIteration - CPython prints a traceback ending in that line. Verified by execution.
Check
Mind the brackets.
g = (x * x for x in range(4))
print(type(g))| expression | result |
|---|---|
| type(g) | ? |
Check your understanding
What does this print?
Answer: A
Why: Round brackets around a comprehension make a generator expression, so its type is generator, not list. Verified by execution.
Check
The generator is stored once.
def gen():
yield 1
yield 2
g = gen()
print(sum(g))
for x in g:
print("again", x)
print("end")| step | output |
|---|---|
| sum(g) | 3 |
| for x in g | ? |
Check your understanding
What does the whole program print?
Answer: A
Why: sum(g) pulls 1 and 2 (total 3) and exhausts g, so the for loop finds nothing and its body never runs; only 'end' follows. Verified by execution.
Trade off
Comparison matrix
From Check: the second loop: every row here is a choice with a cost. Fill the output column, then say which row you would actually pick and what you give up for it.
| step | output |
|---|---|
| sum(g) | 3 |
| for x in g | ? |
Check
Only two values are pulled.
def gen():
print("a")
yield 1
print("b")
yield 2
print("c")
g = gen()
print(next(g))
print(next(g))| pull | prints |
|---|---|
| next #1 | ? |
| next #2 | ? |
Check your understanding
What is the full output, in order?
Answer: A
Why: First next runs 'a' then yields 1; second next resumes, runs 'b' then yields 2. 'c' never runs because we stop pulling after two. Verified by execution.
Comparison
Comparison matrix
From Check: pause and resume: refill the prints column from what you know. The rest of the table is as it appeared.
| pull | prints |
|---|---|
| next #1 | ? |
| next #2 | ? |
Check
Notice the keyword in the loop.
def firsts(n):
for i in range(n):
return i
print(firsts(5))| you write | result |
|---|---|
| firsts(5) | ? |
Check your understanding
What does this print?
Answer: A
Why: There is no yield, so this is a normal function. return i fires on the first pass (i = 0) and ends the function, handing back 0. Verified by execution.
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — How for Really Works · The StopIteration Signal · Generators with yield · Pause and Resume · Generator Expressions · The Memory Win. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
A for loop is iter() plus repeated next(), stopping on StopIteration. A generator (a yield function or a (...) expression) is an iterator you build - lazy, memory-light, and single-use.
| You write | It means |
|---|---|
| iter(nums) | make an iterator (a moving bookmark) |
| next(it) | next value; StopIteration when empty |
| yield x | hand back x and pause the function here |
| (x*x for x in it) | a lazy generator expression |
| second loop over it | nothing - a generator is used up once |
Use generators when you pass through data once or the sequence is huge or endless; use a list when you need to keep and reuse the values. Next session we put these to work streaming real data.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.