All 24 modules are open from the start — nothing here is
locked, and nothing costs anything. Sign in so your progress, titles and
credentials stay with you, on every device you use.
Free forever, with your Google account. No password, no payment.
Every for loop you have written has been quietly using a
protocol you have never seen. This module shows it, and then shows the thing it
makes possible: a function that produces a sequence lazily, one value
at a time, without ever building the whole thing. That is how you process a file
larger than memory, and how a pipeline of transformations costs one pass instead
of four.
Ready?
1
What a for Loop Actually Does
Two words that sound alike and are not. An iterable is
something that can produce an iterator — a list, a string, a dictionary,
a file. An iterator is the thing that keeps the place.
numbers = [1, 2]
it = iter(numbers) # ask the iterable for an iterator
next(it) # 1
next(it) # 2
next(it) # StopIteration
A for loop is exactly that, with the exception caught: call
iter(), call next() until
StopIteration arrives, and stop. Nothing else is happening.
next() takes an optional second argument, which is returned
instead of raising when there is nothing left:
next(iter([]), "nothing") # "nothing"
The distinction matters because a list can be walked over and over — each
for loop asks for a fresh iterator — while an iterator
itself has one position and moves only forward.
A file is an iterator
Which is why looping over the same open file twice gives you every line
and then nothing at all. It is not a list that happens to be lazy; it
is a position that has reached the end.
Quick check
What ends a for loop over a list?
2
yield: a Function That Pauses
A function containing yield is a
generator function. Calling it runs
none of the body — it hands back a generator object and
waits.
def counter(n):
print("starting")
for i in range(n):
yield i
g = counter(3) # prints nothing
next(g) # NOW "starting" is printed, and 0 comes back
Each yield hands a value out and pauses, keeping
every local variable exactly as it was. The next next()
resumes on the line after the yield. When the function
finally returns, StopIteration is raised and the loop ends.
That pausing is the whole trick. A generator holds one value and a
position, whatever the length of what it is producing — so this is
perfectly reasonable:
def naturals():
n = 1
while True:
yield n
n += 1
An infinite sequence, costing nothing until something asks. Taking a
finite piece of it is itertools.islice, or a
break.
Quick check
How much of the body runs when a generator function is called?
3
Chaining Them Into a Pipeline
Because a generator both consumes and produces lazily, generators plug
into each other — and the whole chain costs one pass over the
data, holding one item at a time.
def non_blank(lines):
for line in lines:
if line.strip():
yield line.strip()
def errors_only(lines):
for line in lines:
if line.startswith("ERROR"):
yield line
for line in errors_only(non_blank(raw_lines)):
...
With lists, each of those steps would build a complete new list. With
generators nothing is built at all: each next() at the end
pulls one item through the whole chain.
yield from hands over to another iterable, yielding
everything it produces. It is how you flatten, and how one generator
delegates to another:
def flatten(rows):
for row in rows:
yield from row # instead of an inner for loop
And itertools has the pieces you would otherwise write:
islice to take the first n, chain to run
several iterables end to end, count for an endless
sequence of numbers.
Quick check
Three generators chained, over a million-line file. How many lines are held at once?
4
Walked Once, and Then Empty
A generator is an iterator, so it has one position and it only moves
forward. Once exhausted it stays exhausted — and, crucially, it does not
complain:
g = counter(3)
list(g) # [0, 1, 2]
list(g) # [] — no error, no warning
This is the bug people actually hit. Code that works when it uses a
result once starts producing empty output the day somebody adds a second
use — a count and then a loop, say — and there is nothing in the
traceback because there is no traceback.
Two fixes, and the choice is a real one:
1
Materialise it
items = list(gen) once, then use the list as often as you like. Costs the memory the generator was avoiding.
2
Call the function again
Keep the generator function around rather than one generator object, and get a fresh one per pass. Costs a second read of the source.
And the honest limits. A generator has no len(), no
indexing, and no way back. If you need any of those, you need a list —
and the memory argument only pays when the data is genuinely large or
genuinely endless. For four hundred rows, build the list and stop
thinking about it.
When it is worth it
A file bigger than memory. An endless stream. A pipeline where an early
filter throws most of the data away. Those are the cases. A
three-element list dressed up as a generator is a slower list with
fewer features.
Laziness buys memory and costs re-use. Know which one you needed.
Quick check
You count a generator's items and then loop over it. What does the loop see?
0 of 9 completed
Real Python runs right here in your browser — nothing to install, nothing
sent to a server. The interpreter downloads once the first time you press
Run, then stays cached.
01
To do
iter() asks an iterable for an iterator.
next() pulls the following value out of it, and raises
StopIteration when there is nothing left.
That is the whole of what a for loop does: call
iter(), call next() until the exception
arrives, catch it, stop.
next() also takes a second argument, returned instead of
raising when the iterator is finished.
Your task: walk the list by hand and print four lines —
the two values, the word StopIteration when it runs out, and
a default from an empty iterator:
a b StopIteration nothing
your_code.py
PythonCtrl↵ to run
Hint
it = iter(letters), then print(next(it)) twice. Wrap the third call in try/except StopIteration and print the word. The last line is next(iter([]), "nothing").
Output
02
To do
Each for loop over a list asks for a fresh
iterator, so a list can be walked as often as you like. An
iterator has one position and moves only forward.
Your task: show both halves. Walk the list twice, then
walk one iterator twice, collecting what each pass sees:
[1, 2, 3] [1, 2, 3] [1, 2, 3] []
The fourth line is empty because the third consumed the iterator — with
no error to say so.
your_code.py
PythonCtrl↵ to run
Hint
The first two are list(numbers) each time — a list hands out a new iterator per pass. The last two are both list(it), on the same iterator.
Output
03
To do
A function containing yield is a generator function. Each
yield hands a value out and pauses, keeping every local
variable exactly where it was.
def steps(): yield "build" yield "test"
Your task: write countdown(n) yielding
n down to 1 and then the string "go". Print the
whole thing as a list, then loop over a fresh one:
[3, 2, 1, 'go'] 2 1 go
The loop starts at 2, so call it with a smaller number the second time.
your_code.py
PythonCtrl↵ to run
Hint
A while loop or a range walking downwards, with yield inside it, then one final yield "go" after the loop. Nothing is returned — the values leave through yield.
Output
04
To do
Calling a generator function runs no part of the body.
It builds a generator object and waits. Even a
print on the first line does not happen until the first
value is asked for.
This surprises people, and it is the whole basis of laziness: work
happens when a value is needed and not before.
Your task: demonstrate it. Build the generator, print a
line to show nothing has happened, then consume it:
built, nothing has run starting 0 1 2
your_code.py
PythonCtrl↵ to run
Hint
g = counter(3) builds it. Then a for loop over g prints each value — and "starting" appears at that moment, not before.
Output
05
To do
A generator holds one value and a position, so producing an endless
sequence is perfectly reasonable — nothing is computed until it is asked
for.
def naturals(): n = 1 while True: yield n n += 1
Taking a finite piece is itertools.islice(source, n), or a
break. What you must never do is call list() on
it.
Your task: write naturals(), then take the
first five with islice, and the first three squares over 50
with a loop and a break:
[1, 2, 3, 4, 5] [64, 81, 100]
your_code.py
PythonCtrl↵ to run
Hint
naturals is a while True loop yielding n and incrementing it. first_five = list(islice(naturals(), 5)). For the squares, loop over naturals(), skip when n * n is 50 or less, append otherwise, and break once big_squares has three.
Output
06
To do
A generator both consumes and produces lazily, so they chain — and the
whole chain costs one pass, holding one item at a time. With lists, each
stage would build a complete new list.
for line in errors_only(non_blank(raw)): ...
Your task: write two generators —
non_blank(lines), which yields the stripped non-empty lines,
and errors_only(lines), which yields only those starting
with ERROR. Chain them, then print the result and the count:
['ERROR db timeout', 'ERROR disk full'] 2
Neither generator may build a list.
your_code.py
PythonCtrl↵ to run
Hint
Each one is a for loop with an if and a yield — no list, no append, no return. non_blank strips first so errors_only sees a line with no leading spaces.
Output
07
To do
yield from yields everything another iterable produces. It
replaces an inner loop, and it is how one generator delegates to another.
def flatten(rows): for row in rows: yield from row
Your task: write flatten(rows) using
yield from, and numbered(rows) which delegates
to it and yields (position, value) pairs starting at 1:
flatten is a for loop with yield from row inside. numbered can loop enumerate(flatten(rows), start=1) and yield each pair — the tuple is what enumerate already hands you.
Output
08
To do
This code counts the results and then reports them, and reports nothing
at all. The count consumed the generator; the loop found an iterator
that had already reached the end.
No error is raised. Code like this works for months, until somebody adds
a second use of a result that was only ever used once.
Your task: fix it so both the count and the listing
work. Keep squares a generator function — the fix is at the
call site, not in the generator.
Count: 3 1 4 9
your_code.py
PythonCtrl↵ to run
Hint
Two honest options. Either materialise it once — results = list(squares(3)) — and use the list twice, or call squares(3) again for the second pass. The list is the right call here: three items, used twice.
Output
09
To do
Laziness buys memory and costs re-use. When the data is small and the
code wants len(), indexing, or a second pass, a generator is
a slower list with fewer features.
The code below wants all three, and works around the generator three
times — a sum(1 for _ in ...) to count, a rebuild to index,
and another rebuild to loop.
Your task: make load return a list, and
simplify all three call sites:
3 whisk ['tea', 'whisk', 'cloth']
your_code.py
PythonCtrl↵ to run
Hint
Make load return a list — a comprehension over raw.split(",") does it in one line. Then the three calls become len(names), names[1] and names, with load called once.
Output
The streaming pipeline
To do
A log arrives as a stream — in this exercise a list, in production a file
too big to hold. Build a pipeline of generators that walks it once,
throwing away what it does not need as it goes, and takes only as much as
the report asks for.
Write four generator functions. None of them may build a
list, call len(), or return anything — every value leaves
through yield:
clean(lines) — yields the stripped lines, dropping blanks and anything starting with #
parse(lines) — yields (level, message), splitting each line with maxsplit=2 and dropping the timestamp
only(entries, level) — yields the entries matching one level
messages(entries) — yields just the message from each entry
Then chain all four and take the first two errors with
itertools.islice. Also count every entry — with a
second pass, since the first one is spent.
Print exactly five lines:
db timeout after 30s disk full on /var Errors shown: 2 Entries in total: 6 Levels: ERROR, INFO, WARN
The last line is every distinct level, sorted — which needs a third pass,
because the other two are gone.
your_code.py
PythonCtrl↵ to run
Hint
Each generator is a for loop with a yield and no collecting. In parse, line.split(maxsplit=2) gives three pieces and you keep the last two. Chain with messages(only(parse(clean(raw)), "ERROR")) and wrap that in islice(..., 2). The count is a second full chain — sum(1 for _ in parse(clean(raw))) — and the levels are a set built from a third, sorted and joined with ", ".