Session 30 - Iterators & Generators

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

What this lesson covers

The lesson, slide by slide

1. Iterators & Generators

Title

Python Fundamentals - Session 30

How for really works, and how to hand back values one at a time

2. What you will be able to do

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:

  1. Explain how a for loop uses iter() and next() under the hood.
  2. Predict when next() raises StopIteration and how for handles it.
  3. Write a generator with yield and trace how it pauses and resumes.
  1. Tell a generator function from a generator expression and pick each.
  2. Explain the memory win for large or infinite sequences.
  3. Know why a generator is used up after one pass.

3. What survived from Session 29 - Lambdas & Functional Tools?

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

4. How for Really Works

Section

Part 1

5. A for loop hides two steps

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.

6. Break it if you can: A for loop hides two steps

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.

7. iter() gets an iterator; next() pulls a value

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.

8. By analogy: iter() gets an iterator; next() pulls a value

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.

9. What has to happen first: iter and next by hand

Ranking

Put in order

Put the moves of iter and next by hand into the order they have to happen.

  1. iter(nums) builds an iterator over the list
  2. Each next(it) returns one value and advances
  3. Read the output

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.

10. iter and next by hand

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.

callreturnsposition now
iter(nums)an iteratorbefore 10
next(it)10after 10
next(it)20after 20
next(it)30after 30

11. Fill in: returns for iter and next by hand

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.

callreturnsposition now
iter(nums)an iteratorbefore 10
next(it)10after 10
next(it)20after 20
next(it)30after 30

12. An iterator is a bookmark

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.

13. Teach it back: An iterator is a bookmark

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.

14. Predict the next row: Strings are iterable too

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

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

15. Strings are iterable too

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.

callreturns
iter("hi")iterator over 'h','i'
next(it)h
next(it)i

16. What each one costs: Strings are iterable too

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.

callreturns
iter("hi")iterator over 'h','i'
next(it)h
next(it)i

17. Restore the missing line: enumerate hands back an iterator

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.

18. enumerate hands back an iterator

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.

callreturns
next(it)(0, 'a')
next(it)(1, 'b')

19. Inspect it line by line: enumerate hands back an iterator

Error analysis

Annotate

Walk the callouts on enumerate hands back an iterator. Each one is a place this is easy to get subtly wrong.

  • Each next(it) yields a (index, value) tuple.
  • Verified by execution: (0, 'a'), then (1, 'b'). The tools you already use are iterators underneath.

20. An iterator's own iter() is itself

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.

expressionresult
iter(it) is itTrue

21. Draw the shape of it: An iterator's own iter() is itself

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.

22. The StopIteration Signal

Section

Part 2

23. Past the end, next() raises StopIteration

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.

24. Take the definitions apart: iterator vs StopIteration

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.

iterator
An object that produces values one at a time and remembers its position.; it has no length and no going back.
StopIteration
The signal an iterator raises to say 'I am empty'.; Calling next() on a used-up iterator raises it.
b1
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.
b2
The signal an iterator raises to say 'I am empty'. Calling next() on a used-up iterator raises it.

25. Finish it with less help: One next() too many

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.

26. One next() too many

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.

callresult
next(it)10
next(it)20
next(it)StopIteration (raised)

27. for catches StopIteration for you

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.

28. Plan first: Rewrite for by hand

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:

  1. Each pass pulls one value
  2. The fourth pull is empty, so we break
  3. Trace it pass by pass

29. Rewrite for by hand

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.

passnext(it)prints
111
222
333
4StopIterationbreak

30. Watch it run: Rewrite for by hand

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?

  1. Step 1: pass is 1
  2. Step 2: pass is 2
  3. Step 3: pass is 3
  4. Step 4: pass is 4

31. Generators with yield

Section

Part 3

32. yield makes a generator function

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.

33. Term to definition: Session 30 - Iterators & Generators

Matching

Match the pairs

Match each term to the definition this lesson gave it — not the one you would guess from the word.

  • t1. iterator
  • t2. StopIteration
  • t3. generator
  • d1. 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.
  • d2. The signal an iterator raises to say 'I am empty'. Calling next() on a used-up iterator raises it.
  • d3. 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.

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.

34. Calling it does not run the body

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.

35. A count-up generator

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.

ii <= nyields
1True1
2True2
3True3
4Falsestops

36. What happens as it grows: A count-up generator

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?

  1. Step 1: i is 1
  2. Step 2: i is 2
  3. Step 3: i is 3
  4. Step 4: i is 4

37. Restore the missing line: The generator object, up close

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.

38. The generator object, up close

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.

callresult
count_up_to(3)a generator object
next(g)1
next(g)2

39. Every generator is an iterator

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.

40. yield is a pause button

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.

41. Pause and Resume

Section

Part 4

42. Each next() resumes after the last yield

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.

43. Plan first: Watch it pause and resume

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:

  1. gen() runs nothing yet
  2. First next(g): top down to yield 1
  3. Second next(g): resumes after yield 1
  4. Trace the output order

44. Watch it pause and resume

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.

steprunsprints
gen()nothing yet-
next(g) #1start -> yield 1start, then 1
next(g) #2resumed once -> yield 2resumed once, then 2

45. Where the cost goes: Watch it pause and resume

Cost model

Annotate

In Watch it pause and resume, before reading the notes: mark where the time actually goes. Which line dominates?

  • The 'start' line does not print at call time - the body is frozen.
  • Prints start, then yields 1 and pauses right there.
  • Prints 'resumed once', then yields 2 and pauses. 'resumed twice' never runs - we stopped pulling.

46. A running-total generator

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.

ntotalyields
101010
203030
306060

47. Watch it run: A running-total generator

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?

  1. Step 1: n is 10
  2. Step 2: n is 20
  3. Step 3: n is 30

48. A generator without a loop

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
1red
2green
3blue

49. Watch it run: A generator without a loop

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?

  1. Step 1: yield # is 1
  2. Step 2: yield # is 2
  3. Step 3: yield # is 3

50. Generator Expressions

Section

Part 5

51. A one-line generator

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.

52. State the rule before it runs: A generator expression in action

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.

53. A generator expression in action

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.

callresult
(x*x for x in nums)a generator object
next(squares)1
next(squares)4

54. Generator expression vs list comprehension

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.

55. What has to happen first: Different types, same values

Ranking

Put in order

Put the moves of Different types, same values into the order they have to happen.

  1. Round brackets give a generator; square give a list
  2. A generator expression can feed a function directly
  3. Read the output

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.

56. Different types, same values

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

expressionresult
type(gen)<class 'generator'>
type(lst)<class 'list'>
sum(x*x for x in range(4))14

57. Filtering in a generator expression

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

nn % 2 == 0kept?
4True4
7False-
10True10
3False-
8True8

58. Fill in: kept? for Filtering in a generator expression

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.

nn % 2 == 0kept?
4True4
7False-
10True10
3False-
8True8

59. The Memory Win

Section

Part 6

60. Lazy means light

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.

61. Plan first: Measure the memory gap

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:

  1. The list stores a million integers
  2. The generator stores almost nothing
  3. Compare the sizes

62. Measure the memory gap

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.

objectgetsizeof (bytes)
list of 1,000,0008448728
generator192

63. Generators can be infinite

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.

64. Restore the missing line: An endless count-up, stopped by break

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.

65. An endless count-up, stopped by break

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.

xx > 13action
10Falseprint 10
11Falseprint 11
12Falseprint 12
13Falseprint 13
14Truebreak

66. What happens as it grows: An endless count-up, stopped by 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?

  1. Step 1: x is 10
  2. Step 2: x is 11
  3. Step 3: x is 12
  4. Step 4: x is 13
  5. Step 5: x is 14

67. Conveyor belt, not warehouse

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.

68. Teach it back: Conveyor belt, not warehouse

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.

69. Used Up After One Pass

Section

Part 7

70. A generator is single-use

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.

71. By analogy: A generator is single-use

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.

72. Predict the next row: The second loop is 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

loopyields
first loop0, 1, 4
second loop(nothing)
afterdone

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.

73. The second loop is empty

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.

loopyields
first loop0, 1, 4
second loop(nothing)
afterdone

74. Watch it run: The second loop is empty

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?

  1. Step 1: loop is first loop
  2. Step 2: loop is second loop
  3. Step 3: loop is after

75. Plan first: list() twice tells the same story

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:

  1. The first list() collects everything
  2. The second list() gets an empty list
  3. Read the output

76. list() twice tells the same story

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 [].

callresult
list(g) first[0, 1, 4]
list(g) again[]

77. What has to happen first: Summing the same generator twice

Ranking

Put in order

Put the moves of Summing the same generator twice into the order they have to happen.

  1. The first sum() consumes all five values
  2. The second sum() adds nothing
  3. Read the output

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.

78. Summing the same generator twice

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.

callresult
sum(g) first10
sum(g) again0

79. Draw the shape of it: Summing the same generator twice

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.

80. Need it twice? Rebuild or store

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.

81. Break it if you can: Need it twice? Rebuild or store

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.

82. Traps

Section

Part 8

83. Something is wrong here: reusing an exhausted generator

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.

84. Trap: reusing an exhausted generator

Trap

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

stepresult
sum(g)5
max(g)ValueError: max() iterable argument is empty

The fix

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.

callresult
sum(squares(3))5
max(squares(3))4

85. Break it on purpose: reusing an exhausted generator

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.

86. Something is wrong here: calling next() past the end

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.

87. Trap: calling next() past the end

Trap

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

callresult
next(g)a
next(g)b
next(g)StopIteration (raised)

The fix

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.

callresult
next(g)a
next(g)b
next(g, "done")done

88. Inspect it line by line: Trap: calling next() past the end

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.

  • After 'a' and 'b' the generator is empty, so next() raises. CPython prints a traceback ending in StopIteration and the program crashes.
  • The second argument is a fallback for 'empty'. Real output: a, b, done - no crash. A for loop handles the ending for you automatically.

89. Something is wrong here: return where you meant yield

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.

90. Trap: return where you meant yield

Trap

The 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 writeresult
get_evens(6)0 (just the first)

The fix

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 writeresult
list(get_evens(6))[0, 2, 4]

91. Which of these survive contact with Session 30 - Iterators & Generators?

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.

Holds up
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.
Breaks
Storing the generator, then passing over it twice.; Pulling more values than the generator has.
sound
These are stated as this lesson states them — each one survives the edge cases Session 30 - Iterators & Generators puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

92. Patterns & Checks

Section

Part 9

93. The iterator protocol

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.

94. Writing a generator

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.

95. Generator expression or list comprehension?

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.

96. Where this shows up: Session 30 - Iterators & Generators

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.

97. Check: iter and next

Check

Track the position.

it = iter([5, 6, 7])
print(next(it))
print(next(it))
callreturns
next(it)?
next(it)?

Check your understanding

What does this print?

  • A. 5 then 6 (correct)
  • B. 5 then 5
  • C. 6 then 7
  • D. [5, 6, 7]

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.

Why B tempts people
next() advances the position, so the second call cannot return 5 again - it moves on to 6.
Why C tempts people
The first next() returns 5, not 6 - the iterator begins at the first item.
Why D tempts people
next() returns one value at a time, not the whole list.

98. Check: one pull too many

Check

There are two values.

it = iter([1, 2])
print(next(it))
print(next(it))
print(next(it))
callresult
next(it) #3?

Check your understanding

What happens on the third next(it)?

  • A. It raises StopIteration (correct)
  • B. It returns None
  • C. It returns 2 again
  • D. It restarts and returns 1

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.

Why B tempts people
next() does not return None when empty; it raises StopIteration (only next(it, default) would return a fallback).
Why C tempts people
An iterator never repeats a value - it only moves forward.
Why D tempts people
Iterators do not reset; there is no going back to the start.

99. Check: which type?

Check

Mind the brackets.

g = (x * x for x in range(4))
print(type(g))
expressionresult
type(g)?

Check your understanding

What does this print?

  • A. <class 'generator'> (correct)
  • B. <class 'list'>
  • C. <class 'tuple'>
  • D. [0, 1, 4, 9]

Answer: A

Why: Round brackets around a comprehension make a generator expression, so its type is generator, not list. Verified by execution.

Why B tempts people
Square brackets would make a list; these are round brackets, so it is a generator.
Why C tempts people
Round brackets here are a generator expression, not a tuple - a tuple would need actual values, e.g. (0, 1, 4).
Why D tempts people
type() reports the kind of object, and the generator has not been expanded into a list anyway.

100. Check: the second loop

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")
stepoutput
sum(g)3
for x in g?

Check your understanding

What does the whole program print?

  • A. 3 then end (correct)
  • B. 3, again 1, again 2, end
  • C. 3 then 3
  • D. It raises StopIteration

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.

Why B tempts people
The generator is already drained by sum(g), so the for loop yields nothing - 'again' never prints.
Why C tempts people
sum(g) prints 3 once; the loop adds no output, so 3 is not printed twice.
Why D tempts people
A for loop catches StopIteration silently - looping over an empty generator is safe, not an error.

101. What each one costs: Check: the second loop

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.

stepoutput
sum(g)3
for x in g?

102. Check: pause and resume

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))
pullprints
next #1?
next #2?

Check your understanding

What is the full output, in order?

  • A. a, 1, b, 2 (correct)
  • B. a, b, c, 1, 2
  • C. a, 1, b, 2, c
  • D. 1, 2

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.

Why B tempts people
The body does not run all at once - it pauses at each yield, interleaving prints with the yielded values.
Why C tempts people
'c' comes after the second yield; since we only pull twice, that line is never reached.
Why D tempts people
The print lines inside the generator also run, so 'a' and 'b' appear alongside 1 and 2.

103. Fill in: prints for Check: pause and resume

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.

pullprints
next #1?
next #2?

104. Check: return vs yield

Check

Notice the keyword in the loop.

def firsts(n):
    for i in range(n):
        return i

print(firsts(5))
you writeresult
firsts(5)?

Check your understanding

What does this print?

  • A. 0 (correct)
  • B. 0 1 2 3 4
  • C. a generator object
  • D. [0, 1, 2, 3, 4]

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.

Why B tempts people
return stops the function immediately, so only the first value is ever produced, not all five.
Why C tempts people
Without a yield anywhere, the function is not a generator - it returns a plain value.
Why D tempts people
To collect all values you would need yield plus list(); return gives a single value.

105. Connect it up: Session 30 - Iterators & Generators

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.

106. What you can do now

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 writeIt means
iter(nums)make an iterator (a moving bookmark)
next(it)next value; StopIteration when empty
yield xhand back x and pause the function here
(x*x for x in it)a lazy generator expression
second loop over itnothing - 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.

Sources

  1. Python 3 Tutorial - Iterators
  2. Python 3 Tutorial - Generators
  3. Python 3 Reference - Yield expressions
  4. All snippets and error messages executed and copied from CPython 3.12. — Author verification run, 2026-07-15 (Python Fundamentals series, Session 30).

Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108