Generators and yield - Suspended Execution at Engineering Depth
Reading time: ~35 minutes | Level: Intermediate → Engineering
Before reading further, predict the exact output of this program, line by line:
def counter(n):
print("starting")
for i in range(n):
print(f"before yield {i}")
yield i
print(f"after yield {i}")
gen = counter(3)
print("created")
x = next(gen)
print(f"got {x}")
y = next(gen)
print(f"got {y}")
Write out every line you expect to see, in order.
Most developers either guess that everything prints when counter(3) is called, or they know about laziness but get the interleaving wrong. The actual output:
created
starting
before yield 0
got 0
after yield 0
before yield 1
got 1
The function body does not execute when counter(3) is called. It executes when next() is called, runs until it hits yield, suspends, and returns control to the caller. The next next() call resumes from exactly where it left off - including the print after the yield.
Generators are not just lazy lists. They are suspended coroutines - functions that can be paused and resumed mid-execution, preserving their entire local state between calls.
What You Will Learn
- What
yieldactually does to a function's execution frame - The generator object and its
__iter__/__next__protocol - The generator state machine: Created → Running → Suspended → Closed
- The
send()protocol: sending values back into a running generator yield from: delegating to sub-generators and transparent passthroughthrow()andclose(): injecting exceptions and terminating generatorsStopIterationand thereturnvalue of a generator- Memory efficiency: why generators change what problems are solvable
- Generator expressions vs generator functions
- Real-world: Django querysets, asyncio, and data pipelines
Prerequisites
- Lesson 01: Lambda Expressions
- Lesson 02: map, filter, reduce (particularly lazy iterators)
- Python Foundation: functions, for loops, iterables
Part 1 - What yield Does
A Regular Function vs a Generator Function
A regular function runs to completion and returns a single value:
def regular():
print("running")
return 42
result = regular() # prints "running", returns 42
print(result) # 42
A generator function contains yield. The presence of yield transforms the function's behaviour completely:
def generator():
print("running")
yield 42
gen = generator() # prints nothing - body does not execute yet
print(gen) # <generator object generator at 0x...>
result = next(gen) # prints "running", suspends at yield, returns 42
print(result) # 42
The critical difference: generator() does not execute the function body. It creates and returns a generator object. The body only begins executing when next() is called on that object.
What the CPython Frame Contains
When a generator suspends at yield, Python saves the entire execution frame:
- All local variables at their current values
- The instruction pointer - exactly which bytecode instruction to resume from
- The stack - any intermediate values in the expression evaluation stack
- The exception state
This is not a snapshot - it is the live frame, kept alive in the generator object. When next() is called again, Python resumes from the saved instruction pointer with all locals intact.
def demonstrate_locals():
x = 10
print(f"x before yield: {x}")
yield x # suspends here; x = 10 is preserved in frame
x = x + 5
print(f"x after resume: {x}")
yield x
gen = demonstrate_locals()
print(next(gen)) # prints "x before yield: 10", yields 10
print(next(gen)) # prints "x after resume: 15", yields 15
Part 2 - The Generator State Machine
A generator object moves through four states:
import inspect
def my_gen():
yield 1
yield 2
gen = my_gen()
print(inspect.getgeneratorstate(gen)) # GEN_CREATED
next(gen)
print(inspect.getgeneratorstate(gen)) # GEN_SUSPENDED
next(gen)
print(inspect.getgeneratorstate(gen)) # GEN_SUSPENDED
try:
next(gen)
except StopIteration:
pass
print(inspect.getgeneratorstate(gen)) # GEN_CLOSED
StopIteration and the return Value
When a generator function's body completes (either by reaching the end or by an explicit return), Python raises StopIteration. If there is an explicit return with a value, that value is attached to the StopIteration exception as its .value attribute:
def gen_with_return():
yield 1
yield 2
return "done" # not yielded - becomes StopIteration.value
gen = gen_with_return()
print(next(gen)) # 1
print(next(gen)) # 2
try:
next(gen)
except StopIteration as e:
print(f"Stopped with value: {e.value}") # Stopped with value: done
The return value is rarely used directly in simple generators. It becomes important with yield from and the coroutine protocol.
Part 3 - The send() Protocol
Sending Values Into a Generator
next(gen) is equivalent to gen.send(None). But generators also support gen.send(value) - this resumes the generator and makes the yield expression evaluate to value:
def accumulator():
total = 0
while True:
value = yield total # yield sends total OUT; receives value IN
if value is None:
break
total += value
gen = accumulator()
next(gen) # must prime with next() or send(None) first - reaches first yield
print(gen.send(10)) # total = 10, yields 10
print(gen.send(20)) # total = 30, yields 30
print(gen.send(5)) # total = 35, yields 35
The flow of yield in this context:
yield total- suspends execution and sendstotalto the caller.send(value)- resumes execution and makesyield totalevaluate tovalue
This is the foundation of Python's coroutine model. A generator that both receives and sends values is a coroutine.
:::warning Always Prime a Generator Before Sending Non-None Values
The first call to a generator must be next(gen) or gen.send(None). The generator must reach its first yield before it can receive a value via send().
gen = accumulator()
gen.send(10) # TypeError: can't send non-None value to a just-started generator
# Correct:
next(gen) # prime it first
gen.send(10) # now safe
This is the most common mistake when working with the send() protocol.
:::
Part 4 - yield from
Basic Delegation
yield from delegates iteration to a sub-generator (or any iterable). It is not just syntactic sugar - it creates a transparent bidirectional channel:
def inner():
yield 1
yield 2
yield 3
def outer():
yield 0
yield from inner() # delegate to inner
yield 4
print(list(outer())) # [0, 1, 2, 3, 4]
Without yield from, you would need:
def outer_manual():
yield 0
for item in inner(): # works for next(), but does NOT pass send/throw through
yield item
yield 4
The manual for loop breaks the send() and throw() protocols. yield from passes them through transparently.
yield from and the return Value
yield from captures the return value of the sub-generator:
def sub():
yield 1
yield 2
return "sub_done" # StopIteration.value
def delegating():
result = yield from sub() # result receives "sub_done"
print(f"sub returned: {result}")
yield 99
gen = delegating()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # prints "sub returned: sub_done", yields 99
yield from Is the Foundation of async/await
:::note yield from Made async/await Possible
async def and await in Python 3.5+ are directly built on the yield from machinery. An await expr is essentially yield from expr. The event loop sends values (results of I/O operations) back into suspended coroutines via the send() protocol.
# These are conceptually equivalent (simplified):
async def fetch(url):
result = await http_get(url)
return result
# Under the hood (pre-3.5 style):
def fetch(url):
result = yield from http_get(url)
return result
Understanding yield from is understanding how asyncio actually works.
:::
Part 5 - throw() and close()
throw() - Injecting Exceptions
gen.throw(ExcType, value, traceback) raises an exception at the point where the generator is suspended:
def resilient():
try:
while True:
value = yield
print(f"received: {value}")
except ValueError as e:
print(f"caught ValueError: {e}")
yield "recovered"
gen = resilient()
next(gen) # prime
gen.send("hello") # received: hello
gen.send("world") # received: world
result = gen.throw(ValueError, "bad input")
print(result) # caught ValueError: bad input, then yields "recovered"
close() - Terminating a Generator
gen.close() throws GeneratorExit into the generator at the suspension point. The generator should not yield after this - it should clean up and return:
def managed_resource():
resource = acquire_resource()
try:
while True:
yield resource.read()
finally:
resource.release() # guaranteed to run even on close()
gen = managed_resource()
for i, data in enumerate(gen):
process(data)
if i >= 9:
gen.close() # GeneratorExit raised inside generator; finally block runs
break
:::danger Never Catch StopIteration Inside a Generator (PEP 479)
In Python 3.7+, if a StopIteration propagates out of a generator, Python converts it to a RuntimeError. This prevents a subtle class of bugs where an inner iterator's exhaustion accidentally terminated the outer generator.
def buggy_gen():
items = [1, 2, 3]
it = iter(items)
while True:
try:
yield next(it)
except StopIteration:
return # OK: explicit return from generator
# Old bug (Python 3.6 and earlier): StopIteration escaping a generator
# would silently stop it. Now it's a RuntimeError.
# Correct pattern: use for loop or check differently
def correct_gen(items):
for item in items:
yield item
Always use for loops or explicit termination conditions inside generators rather than catching StopIteration.
:::
Part 6 - Memory Efficiency
The Core Advantage
A generator produces values on demand and discards them once consumed. A list holds all values in memory simultaneously.
import sys
# List: all 10 million numbers in memory at once
big_list = list(range(10_000_000))
print(f"list size: {sys.getsizeof(big_list):,} bytes") # ~80,000,056 bytes
# Generator: one number at a time
big_gen = (x for x in range(10_000_000))
print(f"generator size: {sys.getsizeof(big_gen):,} bytes") # ~104 bytes
The generator is 104 bytes regardless of how many elements it will produce. The list grows linearly.
Infinite Sequences
Generators make infinite sequences possible:
def naturals():
"""Infinite sequence of natural numbers."""
n = 1
while True:
yield n
n += 1
def fibonacci():
"""Infinite Fibonacci sequence."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Take the first 10 Fibonacci numbers
gen = fibonacci()
first_10 = [next(gen) for _ in range(10)]
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Lazy File Processing
def read_large_file(filepath):
"""Read a multi-GB log file one line at a time."""
with open(filepath) as f:
for line in f:
yield line.rstrip()
def parse_errors(filepath):
"""Lazy pipeline: read → filter → parse."""
lines = read_large_file(filepath)
error_lines = (line for line in lines if "ERROR" in line)
for line in error_lines:
timestamp, level, message = line.split(" ", 2)
yield {"timestamp": timestamp, "message": message}
# Processes a 10 GB file with constant memory usage
for error in parse_errors("/var/log/app.log"):
store_to_database(error)
Part 7 - Generator Expressions
Generator expressions are to generators what list comprehensions are to lists - a compact syntax for the common case:
# Generator function
def squares(n):
for i in range(n):
yield i ** 2
# Generator expression - same thing, inline
squares_gen = (i ** 2 for i in range(10))
print(type(squares_gen)) # <class 'generator'>
print(list(squares_gen)) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
Generator expressions are lazy - they do not compute until iterated:
# The filter is not evaluated at construction time
large_gen = (x ** 2 for x in range(10_000_000) if x % 2 == 0)
# Instant - no computation yet
# Only computes as you consume it
first_five = list(itertools.islice(large_gen, 5))
print(first_five) # [0, 4, 16, 36, 64]
When to Use Generator Expressions vs Generator Functions
Use a generator expression when:
- The logic is simple enough to fit on one line
- You do not need
send(),throw(), or complex control flow
Use a generator function when:
- The generation logic is complex
- You need multiple
yieldpoints - You need
send()or coroutine behaviour - You need cleanup in a
finallyblock
Part 8 - Real-World Patterns
Django ORM - Queryset Lazy Evaluation
Django's QuerySet uses a lazy evaluation model directly analogous to generators. The database query is not executed until you iterate the queryset, call list(), or use slicing:
# Django equivalent - no SQL executed yet
users = User.objects.filter(is_active=True).order_by("created_at")
# SQL executed here, one batch at a time with iterator()
for user in users.iterator(chunk_size=1000):
send_newsletter(user)
asyncio - Coroutines Built on Generators
Each await is a yield that suspends the coroutine and returns a future to the event loop. The event loop's send() call resumes the coroutine with the result of the I/O operation. This is the entire model.
Generator-Based Data Pipeline
import csv
import itertools
def read_csv(filepath):
"""Stage 1: read rows from CSV."""
with open(filepath, newline="") as f:
reader = csv.DictReader(f)
yield from reader
def filter_active(rows):
"""Stage 2: keep only active records."""
return (row for row in rows if row["status"] == "active")
def normalise_email(rows):
"""Stage 3: normalise email addresses."""
return (
{**row, "email": row["email"].strip().lower()}
for row in rows
)
def batch(iterable, size):
"""Stage 4: group into batches for bulk insert."""
it = iter(iterable)
while True:
chunk = list(itertools.islice(it, size))
if not chunk:
break
yield chunk
def run_pipeline(filepath):
"""Compose the full pipeline - lazy, constant memory."""
rows = read_csv(filepath)
active = filter_active(rows)
normalised = normalise_email(active)
batches = batch(normalised, 500)
for batch_rows in batches:
db.bulk_insert("users", batch_rows)
print(f"Inserted {len(batch_rows)} rows")
run_pipeline("users.csv")
This pipeline processes a file of any size - 10 rows or 10 million - with constant memory usage. Each stage is a generator that pulls from the previous stage on demand.
Part 9 - itertools and Generator Utilities
The itertools module provides production-grade generator building blocks:
import itertools
# Count forever
for i in itertools.count(start=0, step=2):
if i > 10:
break
print(i) # 0, 2, 4, 6, 8, 10
# Cycle through values
status_cycle = itertools.cycle(["pending", "processing", "done"])
for _ in range(5):
print(next(status_cycle)) # pending, processing, done, pending, processing
# Chain multiple iterables lazily
combined = itertools.chain(range(3), range(5, 8))
print(list(combined)) # [0, 1, 2, 5, 6, 7]
# Take while condition holds
nums = itertools.count(1)
small = list(itertools.takewhile(lambda x: x < 10, nums))
print(small) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
:::tip Use itertools.tee() If You Need to Iterate a Generator Multiple Times
A generator is single-use. itertools.tee() creates n independent iterators from one source, but note that it buffers elements - it is not free.
import itertools
gen = (x ** 2 for x in range(5))
gen1, gen2 = itertools.tee(gen, 2)
print(list(gen1)) # [0, 1, 4, 9, 16]
print(list(gen2)) # [0, 1, 4, 9, 16]
# Warning: if one iterator advances far ahead of the other,
# tee() must buffer those elements in memory.
# For large divergence, converting to list first is usually better.
:::
Engineering Checklist
Before moving on, verify you can answer these without looking:
- Does the generator function body execute when you call
counter(3)? When does it start executing? - What are the four states of a generator and what transitions exist between them?
- What does
yield valuedo? What doesx = yield valuedo differently? - What must you do before calling
gen.send(non_None_value)on a freshly created generator? - What does
yield from sub_gendo that aforloop oversub_gendoes not? - What happens to a
StopIterationthat propagates out of a generator in Python 3.7+? - Why does a generator of 10 million elements use only ~100 bytes of memory?
- How does
async/awaitrelate to generators andyield from?
Key Takeaways
- A function containing
yieldbecomes a generator function. Calling it returns a generator object without executing the body yieldsuspends execution, saves the entire frame (locals, instruction pointer, stack), returns the yielded value to the caller, and waits for the nextnext()call- Generator objects implement the iterator protocol:
__iter__returnsself,__next__resumes execution until the nextyield - The four generator states are: Created → Running → Suspended → Closed
gen.send(value)resumes a suspended generator and makes theyieldexpression evaluate tovalue- the foundation of coroutines- Always prime a generator with
next(gen)orgen.send(None)before sending non-None values yield from sub_gendelegates to a sub-generator with transparent passthrough ofsend(),throw(), andclose()yield fromis the direct predecessor ofasync/await- anawait expris essentiallyyield from expr- Never catch
StopIterationinside a generator - in Python 3.7+ it becomes aRuntimeError(PEP 479) - Generators use constant memory regardless of how many elements they produce - they never hold the full sequence at once
- Generator expressions
(expr for x in seq if cond)are lazy generators inline; use generator functions for complex logic
Graded Practice Challenges
Level 1 - Predict the Output
Question 1: What does this print?
def gen():
yield 1
yield 2
yield 3
g = gen()
print(next(g))
print(next(g))
g.close()
try:
print(next(g))
except StopIteration:
print("stopped")
Show Answer
Output:
1
2
stopped
next(g) consumes yield 1 (prints 1) and yield 2 (prints 2). g.close() throws GeneratorExit into the generator - since the generator does not catch it, the generator terminates and moves to Closed state. The subsequent next(g) raises StopIteration because the generator is closed, caught by the try/except, printing "stopped".
Question 2: What does this print?
def countdown(n):
while n > 0:
yield n
n -= 1
gen = countdown(5)
print(sum(gen))
print(sum(gen))
Show Answer
Output:
15
0
The first sum(gen) consumes all values: 5+4+3+2+1 = 15. The generator is now exhausted (Closed state). The second sum(gen) gets an empty iterator immediately - sum([]) = 0.
Question 3: What does this print?
def annotated():
print("A")
x = yield 1
print(f"B: received {x}")
y = yield 2
print(f"C: received {y}")
gen = annotated()
v1 = next(gen)
print(f"got {v1}")
v2 = gen.send("hello")
print(f"got {v2}")
try:
gen.send("world")
except StopIteration:
print("done")
Show Answer
Output:
A
got 1
B: received hello
got 2
C: received world
done
next(gen) (equivalent to send(None)) runs until yield 1: prints "A", yields 1. gen.send("hello") resumes; x = "hello", prints "B: received hello", runs until yield 2, yields 2. gen.send("world") resumes; y = "world", prints "C: received world", function ends, raises StopIteration.
Question 4: What does this print?
def inner():
yield "a"
yield "b"
return "inner_done"
def outer():
result = yield from inner()
print(f"inner returned: {result}")
yield "c"
print(list(outer()))
Show Answer
Output:
inner returned: inner_done
['a', 'b', 'c']
list(outer()) iterates the outer generator. yield from inner() first yields "a", then "b". When inner() finishes with return "inner_done", yield from captures that as result. The print executes, then yield "c" yields "c". The generator completes and list() collects ["a", "b", "c"].
Question 5: What does this print?
gen_expr = (x * x for x in range(5))
for val in gen_expr:
if val > 5:
break
print(list(gen_expr))
Show Answer
Output:
[9, 16]
The generator expression produces squares: 0, 1, 4, 9, 16. The loop breaks when val > 5, which happens at val = 9 (x=3). At that point the generator is suspended with x=3 consumed. list(gen_expr) resumes and consumes the remaining elements: 16 (x=4). So [9, 16]...
Wait - let me re-trace. The loop: x=0 (val=0, not > 5), x=1 (val=1, not > 5), x=2 (val=4, not > 5), x=3 (val=9, 9 > 5, break). Generator is now suspended after yielding 9 (x=3 is done). The remaining element is x=4 (val=16). So list(gen_expr) returns [16].
Actual output:
[16]
After break, the generator is suspended having yielded 9. The next value to be produced would be x=4 (16). list(gen_expr) consumes the rest: [16].
Level 2 - Debug Challenge
Find and fix all bugs. The intent is a generator that reads a CSV file lazily and yields parsed records, stopping on the first malformed row and printing an error:
import csv
def parse_records(filepath):
with open(filepath) as f:
reader = csv.DictReader(f)
for row in reader:
try:
yield {
"id": int(row["id"]),
"value": float(row["value"]),
}
except (KeyError, ValueError) as e:
print(f"Malformed row: {e}")
return # stop iteration on first error
def process():
for record in parse_records("data.csv"):
print(record)
# The code also has this usage which must be fixed:
records = list(parse_records("data.csv"))
second_run = list(parse_records("data.csv")) # user expects same result
print(len(records) == len(second_run))
Show Solution
Analysis of the generator: parse_records is correctly written. return inside a generator raises StopIteration cleanly - this is the correct way to exit early from a generator.
The bug is in the usage pattern: The developer assumes a generator can be re-used. parse_records("data.csv") creates a fresh generator each time it is called, so second_run = list(parse_records("data.csv")) is actually correct - both calls create new generators from the same file.
Where bugs can hide:
# Bug 1: storing the generator and iterating twice
gen = parse_records("data.csv")
records = list(gen) # works
second_run = list(gen) # [] - exhausted!
# Bug 2: not handling StopIteration from PEP 479
def broken_parse(filepath):
with open(filepath) as f:
reader = csv.DictReader(f)
it = iter(reader)
while True:
row = next(it) # StopIteration propagates out → RuntimeError in Python 3.7+
yield row
# Bug 3: using return value incorrectly
gen = parse_records("data.csv")
result = gen.send("data") # TypeError - generator not primed, and parse_records does not use send
Corrected robust version:
import csv
def parse_records(filepath):
"""Lazily parse CSV records; stop on first malformed row."""
with open(filepath) as f:
reader = csv.DictReader(f)
for row in reader: # for loop handles StopIteration correctly - PEP 479 safe
try:
yield {
"id": int(row["id"]),
"value": float(row["value"]),
}
except (KeyError, ValueError) as e:
print(f"Malformed row: {e}")
return # explicit return - clean StopIteration
# Correct usage: each call creates a fresh generator
records_1 = list(parse_records("data.csv"))
records_2 = list(parse_records("data.csv"))
print(len(records_1) == len(records_2)) # True - both fresh generators from same file
Level 3 - Design Challenge
Design a coroutine-based running statistics tracker that:
- Accepts a stream of numbers via
send() - Yields a dict of
{mean, min, max, count}after each received value - Supports
throw(ResetStats)to reset all statistics - Handles cleanup in a
finallyblock that prints a summary - Uses
yield fromto delegate to a sub-generator that handles the reset logic
# Target usage:
tracker = stats_tracker()
next(tracker) # prime
print(tracker.send(10)) # {'mean': 10.0, 'min': 10, 'max': 10, 'count': 1}
print(tracker.send(20)) # {'mean': 15.0, 'min': 10, 'max': 20, 'count': 2}
print(tracker.send(5)) # {'mean': 11.67, 'min': 5, 'max': 20, 'count': 3}
tracker.throw(ResetStats)
print(tracker.send(100)) # {'mean': 100.0, 'min': 100, 'max': 100, 'count': 1}
tracker.close()
# prints: "Tracker closed. Final count: 1"
Show Reference Solution
class ResetStats(Exception):
"""Signal to reset running statistics."""
pass
def _stats_accumulator():
"""Sub-generator: accumulate stats until ResetStats is thrown."""
total = 0.0
count = 0
minimum = None
maximum = None
while True:
try:
value = yield
count += 1
total += value
minimum = value if minimum is None else min(minimum, value)
maximum = value if maximum is None else max(maximum, value)
# Yield the stats back to outer generator
yield {
"mean": round(total / count, 2),
"min": minimum,
"max": maximum,
"count": count,
}
except ResetStats:
# Reset and start fresh
total = 0.0
count = 0
minimum = None
maximum = None
yield None # acknowledge the reset
def stats_tracker():
"""
Coroutine that tracks running statistics.
send(number) -> yields updated stats dict
throw(ResetStats) -> resets all statistics
"""
final_count = 0
try:
while True:
try:
# Receive a value from caller
value = yield
if value is None:
continue
# Update stats inline (simplified - no sub-generator for clarity)
# Restart accumulation state each iteration
pass
except ResetStats:
yield None # acknowledge reset
finally:
print(f"Tracker closed. Final count: {final_count}")
# Cleaner implementation without yield from complexity:
def stats_tracker():
"""Coroutine tracking running statistics with reset support."""
count = 0
total = 0.0
minimum = None
maximum = None
try:
while True:
try:
value = yield {
"mean": round(total / count, 2) if count else 0.0,
"min": minimum,
"max": maximum,
"count": count,
}
if value is not None:
count += 1
total += value
minimum = value if minimum is None else min(minimum, value)
maximum = value if maximum is None else max(maximum, value)
except ResetStats:
count = 0
total = 0.0
minimum = None
maximum = None
yield None # acknowledge the reset; caller primes again with next()
except GeneratorExit:
print(f"Tracker closed. Final count: {count}")
return
# Usage
tracker = stats_tracker()
next(tracker) # prime - reaches first yield, yields initial empty stats
print(tracker.send(10))
# {'mean': 10.0, 'min': 10, 'max': 10, 'count': 1}
print(tracker.send(20))
# {'mean': 15.0, 'min': 10, 'max': 20, 'count': 2}
print(tracker.send(5))
# {'mean': 11.67, 'min': 5, 'max': 20, 'count': 3}
tracker.throw(ResetStats) # reset; yields None (the acknowledgement)
next(tracker) # re-prime after reset
print(tracker.send(100))
# {'mean': 100.0, 'min': 100, 'max': 100, 'count': 1}
tracker.close()
# prints: Tracker closed. Final count: 1
Key design decisions:
yield valuein the middle of the loop both sends stats out and receives the next input - the two-directional channel of coroutinesexcept ResetStatsinside the generator catchesthrow(ResetStats)at theyieldpointexcept GeneratorExitin the outer try handlesclose()gracefully for cleanupyield Noneafter a reset acknowledges the throw and allows the caller to re-prime withnext()- The generator preserves full state (count, total, min, max) across all
send()calls - this is the point of frame suspension
What's Next
Lesson 04 covers the Iterator Protocol in full - __iter__ and __next__, building custom iterators from scratch, infinite sequences with __iter__, and how Python's for loop is actually implemented as a series of next() calls wrapped in a StopIteration try/except.
You have already seen the iterator protocol in action through generators. Now you will build it explicitly, which reveals why generators are so useful - they implement __iter__ and __next__ automatically, saving you from writing the boilerplate by hand.
