Skip to content
HopperHopper

Course navigation

Phase 4 · Professional habitsDay 18

Iterators, generators and decorators

  • About 80 minutes
  • 8 sections
  • Runnable Python examples
  • Practice questions

Before you start

About 80 minutes

By the end you will be able to

  1. Explain what a for loop does with iter() and next(), and make your own iterator.
  2. Write generator functions with yield, and explain what yield does differently from return.
  3. Use lazy generators for endless sequences and for large files, one value at a time.
  4. Write closures and decorators that wrap functions, keeping their names with functools.wraps.

What to remember from earlier

Make a day18 folder for today’s files, open it in your editor, and run each script from its terminal, as on earlier days. A few programs read small text files; save each one into day18 beside the script, with the name shown.

Phase 4 is about the habits of programmers whose code other people rely on. It starts with three tools you have already used without seeing inside. Every for loop you have written follows a hidden two-step agreement; today you see it and make your own objects follow it. Day 11’s generator expressions have a bigger sibling, the generator function, which can produce an endless stream of values or work through a file far too big for memory. And Day 17’s @property was a decorator; today you find out what a decorator is by writing your own, which needs one more idea first: a function that remembers.

Section 1 of 8

18.1 What a for Loop Really Does: iter() and next()

A for loop works on a list, a string, a dictionary, a range, a file, and a generator. Those are very different things, so how can one statement handle them all? And why does a second loop over a list start again from the beginning, while a second loop over a generator (Day 11) or a file (Day 14) finds nothing left? Both questions have the same answer.

Day 10 named anything a for loop can go through an iterable. Day 14 said a file object keeps a bookmark of how far it has read. That bookmark is the key. Before a for loop starts, it asks the iterable for a bookmark object. Then, on every pass, it asks the bookmark for “the next item, please”, until the bookmark says there are none left.

Python has two built-in functions for those two requests:

  • iter(iterable) returns an iterator: an object that produces the iterable’s items one at a time and remembers which one comes next. It is the bookmark.
  • next(iterator) returns the iterator’s next item and moves the bookmark on by one. When there are no items left, it raises a StopIteration exception, which means “finished”.

The agreement that an iterable hands out an iterator through iter(), and that the iterator hands out items through next() until it raises StopIteration, is called the iteration protocol. A protocol is just an agreed way of doing something, like the order of steps in a handshake.

>>> colors = ["red", "green", "blue"]
>>> bookmark = iter(colors)
>>> next(bookmark)
'red'
>>> next(bookmark)
'green'
>>> next(bookmark)
'blue'
>>> next(bookmark)
Traceback (most recent call last):
  ...
StopIteration
>>> next(bookmark, "no more")
'no more'
  • bookmark = iter(colors) makes an iterator for the list. The list itself does not change.
  • Each next(bookmark) gives the next color, in order.
  • The fourth next has nothing to give, so it raises StopIteration. A traceback here is not a bug in the list; it is how an iterator says “done”.
  • next(iterator, default) with a second argument returns default instead of raising when the iterator is finished, like get() with a default on a dictionary (Day 8).

Worked example: A for loop, written out by hand

The program below loops over the same list twice: once with a for loop, and once doing by hand exactly what the for loop does for you.

for_by_hand.py
scores = [85, 92, 78]

for score in scores:
    print("for loop:", score)

bookmark = iter(scores)
while True:
    try:
        score = next(bookmark)
    except StopIteration:
        break
    print("by hand:", score)
for loop: 85
for loop: 92
for loop: 78
by hand: 85
by hand: 92
by hand: 78
Line What the hand-written version does What the for loop does silently
6 iter(scores) makes an iterator Calls iter() once, at the start
9 next(bookmark) gets the next score and stores it in score Calls next() before each pass
12 Runs the body with that score Runs the body
10–11 After 78, next raises StopIteration; except (Day 15) catches it and break leaves Catches StopIteration and stops

The two halves print the same values because they are the same process. The for loop is a short way to write lines 6 to 12, and that is why it works on anything that can give out an iterator.

Now the second question. A list is an iterable, but it is not an iterator: it keeps no bookmark of its own. Every call to iter() on a list makes a fresh iterator that starts at the first item. So every for loop over a list starts from the beginning. A generator, on the other hand, is its own iterator: iter() on a generator hands back the very same generator, bookmark and all. Once the bookmark reaches the end, every later loop finds it there, which is why a generator can be used only once. A file object works the same way.

>>> scores = [85, 92, 78]
>>> first = iter(scores)
>>> second = iter(scores)
>>> next(first)
85
>>> next(first)
92
>>> next(second)
85
>>> iter(scores) is scores
False
>>> squares = (n * n for n in scores)
>>> iter(squares) is squares
True

first and second are two independent bookmarks on one list, so second still starts at 85 after first has moved on. iter(scores) is scores is False (Day 4’s is asks “the same object?”): the list hands out a new object. iter(squares) is squares is True: the generator hands out itself.

Iterable (list, string, dict, range) Iterator (generator, file, result of iter())
Works in a for loop? Yes Yes
Works with next()? No Yes
What iter() gives back A fresh iterator each time Itself
Loop over it twice Both loops see every item The second loop finds nothing left

Key idea

A for loop calls iter() to get an iterator, then calls next() before each pass until StopIteration is raised. An iterable, such as a list, hands out a fresh iterator each time; an iterator, such as a generator or a file, is its own bookmark, so it can be used only once.

Watch out

Calling next() on a list. next() needs an iterator, and a list is only an iterable:

>>> names = ["Ana", "Ben"]
>>> next(names)
Traceback (most recent call last):
  ...
TypeError: 'list' object is not an iterator
>>> people = iter(names)
>>> next(people)
'Ana'

“Not an iterator” means “this has no bookmark of its own”. Ask the list for one with iter() first, then call next() on the result, as people shows.

Pause and try

Question 1 · Tracing

What does this program print? The for loop and the next calls share one iterator.

cat_trace.py
letters = iter("cat")
print(next(letters))
for letter in letters:
    print(letter.upper())
print(next(letters, "end"))
Check your reasoning
c
A
T
end

Line 1 makes one iterator over "cat". Line 2’s next takes "c" and moves the bookmark to "a". The for loop on line 3 calls iter() on letters, which is already an iterator, so it gets the same bookmark back and carries on from "a": it prints A and T. When the loop ends, the bookmark is at the end, so line 5’s next has nothing to give and returns its default, "end". If you printed C first, remember the loop did not start from the beginning: next had already used the "c".

Reread 18.1 What a for Loop Really Does: iter() and next() if your output differed.

Question 2 · Multiple choice

Which of these can you pass straight to next(), without calling iter() first?

  1. ["a", "b"]
  2. "ab"
  3. (letter for letter in "ab")
  4. {"a": 1, "b": 2}
Check your reasoning

C. A generator expression makes a generator, and a generator is its own iterator, so next() works on it directly and returns "a".

  • A is wrong: a list is an iterable but not an iterator, so next() raises TypeError: 'list' object is not an iterator.
  • B is wrong for the same reason: a string is an iterable, not an iterator.
  • D is wrong too: a dictionary is an iterable (looping over it gives its keys), but it keeps no bookmark, so next() on it raises a TypeError.

Reread 18.1 What a for Loop Really Does: iter() and next() if you chose differently.

Section 2 of 8

18.2 Making Your Own Iterator: __iter__ and __next__

A rocket launch counts down 3, 2, 1. You would like to write for number in Countdown(3): with a class of your own, the way you loop over a range. Day 17 showed how to plug your objects into Python’s built-ins: len(obj) calls obj.__len__(), and a < b calls a.__lt__(b). The iteration protocol works the same way. iter(obj) calls the special method __iter__, and next(obj) calls __next__. A class that defines both is an iterator, and for loops, list(), sum(), and every other tool that loops will accept it.

The rules for the two methods come straight from 18.1:

  • __iter__(self) must return an iterator. An object that is its own bookmark simply returns self.
  • __next__(self) must return the next value and update the bookmark, or raise StopIteration (Day 15’s raise) when there are no values left.

Worked example: A countdown you can loop over

countdown_class.py
class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value


for number in Countdown(3):
    print(number)
print("Liftoff!")

launch = Countdown(2)
print(list(launch))
print(list(launch))
3
2
1
Liftoff!
[2, 1]
[]

The attribute current is the bookmark: it holds the next number to hand out. Here is the for loop on line 16, step by step:

Step current before What __next__ does Loop gets
iter() calls line 5 3 (__iter__ returns the object itself)
1st next() 3 3 is not <= 0; saves 3, lowers current to 2 3
2nd next() 2 saves 2, lowers current to 1 2
3rd next() 1 saves 1, lowers current to 0 1
4th next() 0 0 <= 0, so line 10 raises StopIteration (loop ends)

Lines 11 to 13 save the value before lowering current, so the method can return the old value after updating the bookmark. Lines 20 to 22 show that a Countdown behaves like a generator: it is its own iterator, so the first list() uses it up, and the second finds current already at 0 and gets an empty list.

That is a lot of bookkeeping for “count down from 3”. The class has to store its position in an attribute, update it on every call, and remember to raise StopIteration at the end. If the raise line were missing, __next__ would return None forever (Day 9: a function that reaches its end returns None), and the for loop would never stop until you pressed Ctrl+C. The next subsection shows a much shorter way to write the same thing, but this is what happens underneath it.

Key idea

A class becomes an iterator when it defines __iter__, which returns self, and __next__, which returns the next value or raises StopIteration. An attribute holds the position between calls.

Watch out

Leaving out __iter__. __next__ alone is not enough, because the for loop’s first step is to call iter():

countdown_no_iter.py
class Countdown:
    def __init__(self, start):
        self.current = start

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value


for number in Countdown(3):
    print(number)
Traceback (most recent call last):
  File ".../day18/countdown_no_iter.py", line 13, in <module>
    for number in Countdown(3):
                  ~~~~~~~~~^^^
TypeError: 'Countdown' object is not iterable

“Not iterable” means iter() found no __iter__ method. Add the two-line __iter__ that returns self, as in countdown_class.py.

Pause and try

Question 3 · Tracing

What does this program print?

evens_trace.py
class Evens:
    def __init__(self, limit):
        self.limit = limit
        self.number = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.number > self.limit:
            raise StopIteration
        value = self.number
        self.number += 2
        return value


evens = Evens(5)
print(next(evens))
print(list(evens))
print(sum(Evens(6)))
Check your reasoning
0
[2, 4]
12
Line Call number before Result
18 next(evens) 0 returns 0; number becomes 2
19 list(evens) 2, 4, 6 collects 2 and 4; at 6, 6 > 5 raises StopIteration
20 sum(Evens(6)) 0, 2, 4, 6, 8 a new object: 0 + 2 + 4 + 6 = 12; stops at 8

list() continues from where next() left the bookmark, so 0 is not in the list. Line 20 makes a fresh Evens with its own number starting at 0, and 6 is allowed because the test is > limit, not >= limit.

Reread 18.2 Making Your Own Iterator: __iter__ and __next__ if your output differed.

Section 3 of 8

18.3 Generator Functions and yield

Here is the same countdown with none of the bookkeeping:

countdown_gen.py
def countdown(start):
    current = start
    while current > 0:
        yield current
        current -= 1


for number in countdown(3):
    print(number)
print("Liftoff!")

launch = countdown(2)
print(list(launch))
print(list(launch))
3
2
1
Liftoff!
[2, 1]
[]

The output is identical to countdown_class.py, from five lines instead of thirteen. The new keyword is yield. A def whose body contains yield anywhere is a generator function. It looks like an ordinary function, but it behaves differently in two ways:

  1. Calling it does not run its body. countdown(3) runs none of the lines inside. It immediately returns a generator (the same kind of object a Day 11 generator expression makes), which is an iterator ready to run the body later.
  2. Each next() runs the body only as far as the next yield. yield value hands value to whoever called next(), and then the function pauses right there, keeping all its local variables. The next next() resumes on the line after the yield. When the body reaches its end, the generator raises StopIteration for you.

So the generator function keeps its bookmark automatically: the bookmark is “which line it paused on, and what its local variables held”. In countdown_gen.py, current stays alive between calls, which is exactly what the class had to store in self.current.

Worked example: Watching a generator pause and resume

To see when the body runs, this generator prints a message between its yields:

yield_pause.py
def three_steps():
    print("start")
    yield 1
    print("after the first yield")
    yield 2
    print("after the second yield")


steps = three_steps()
print("made the generator")
print(next(steps))
print(next(steps))
print(next(steps, "finished"))
made the generator
start
1
after the first yield
2
after the second yield
finished
Line Call Body runs Pauses at Printed by line 11–13
9 three_steps() nothing the start (line 10 prints made the generator)
11 next(steps) line 2 prints start; line 3 yields 1 line 3 1
12 next(steps) line 4 prints; line 5 yields 2 line 5 2
13 next(steps, "finished") line 6 prints; the body ends (finished) finished

made the generator comes before start, because line 9 only creates the generator. On line 13 the body runs to its end and raises StopIteration, which next’s default turns into "finished".

Now compare yield with the return you have used since Day 9. Both functions below produce the squares of 1 to 4:

squares_two_ways.py
def squares_list(limit):
    result = []
    for n in range(1, limit + 1):
        result.append(n * n)
    return result


def squares_gen(limit):
    for n in range(1, limit + 1):
        yield n * n


print(squares_list(4))
print(list(squares_gen(4)))
print(sum(squares_gen(4)))
[1, 4, 9, 16]
[1, 4, 9, 16]
30

squares_list builds a whole list, then returns it in one go. squares_gen yields each square as it is asked for and never builds a list. list(squares_gen(4)) collects the yielded values into a list, and sum adds them one at a time (1 + 4 + 9 + 16 = 30) without one. A generator expression, (n * n for n in range(1, limit + 1)), is a one-line short form of squares_gen; a generator function is what you write when the work needs more than one expression, such as several lines, an if, or a while loop.

return value yield value
What the call gives you The value, when the body finishes A generator, at once, before the body runs
How many values One (perhaps a list holding many) As many as the body yields, one per next()
After handing a value The function ends; its locals are gone The function pauses; its locals are kept
Next time A new call starts again from line 1 next() resumes after the yield

A return inside a generator function just ends it early: the generator raises StopIteration, and any value after return is not given to the loop.

Key idea

A function containing yield is a generator function: calling it returns a generator without running the body. Each next() runs the body to the next yield, hands over that value, and pauses with its local variables kept. return ends a function for good with one value; yield hands over a value and waits to continue.

Watch out

Writing return where you mean yield. This function should produce every even number in the list:

evens_return.py
def evens(numbers):
    for n in numbers:
        if n % 2 == 0:
            return n


print(evens([3, 4, 7, 6, 8]))
4

The first even number, 4, reaches return, which ends the function at once, so 6 and 8 are never seen. There is no error, just a wrong answer. When a function should produce several values one after another, use yield:

evens_yield.py
def evens(numbers):
    for n in numbers:
        if n % 2 == 0:
            yield n


print(list(evens([3, 4, 7, 6, 8])))
[4, 6, 8]

Pause and try

Question 4 · Tracing

What does this program print? Notice when each letter is printed.

snack_trace.py
def snacks():
    print("A")
    yield "apple"
    print("B")
    yield "pear"
    print("C")


menu = snacks()
print("ready")
for snack in menu:
    print(snack)
print("done")
Check your reasoning
ready
A
apple
B
pear
C
done

Line 9 makes the generator without running the body, so ready prints first. The for loop’s first next runs line 2 (A) and pauses at line 3, and the loop prints apple. The second next resumes at line 4 (B) and pauses at line 5; the loop prints pear. The third next resumes at line 6, prints C, and reaches the end of the body, which raises StopIteration and ends the loop quietly. Then done. If you left out C, remember that the generator does run the rest of its body before it can report that it has finished.

Reread 18.3 Generator Functions and yield if your output differed.

Question 5 · Multiple choice

A function scores has two lines in its body: yield 90, then yield 75. What does the call scores() give you?

  1. 90
  2. [90, 75]
  3. A generator that has not run any of the body yet
  4. None, because there is no return
Check your reasoning

C. Calling a generator function runs none of its body. It returns a generator, and the body runs only as next() asks for values.

  • A is wrong: 90 is what the first next() on the generator gives, not the call itself.
  • B is wrong: no list is built. list(scores()) would make [90, 75] by asking for every value.
  • D is wrong: the yield makes this a generator function, and those always return a generator, with or without return.

Reread 18.3 Generator Functions and yield if you chose differently.

Section 4 of 8

18.4 Lazy Evaluation: Endless and Large Inputs

A shop hands out ticket numbers T-100, T-101, T-102, and so on, for as long as customers keep arriving. How many should a function make? A list has to stop somewhere, and you cannot know where. A generator does not have to stop at all.

Day 11 called a generator lazy: it does the work only when a value is asked for. The proper name for this is lazy evaluation: working out each value at the moment it is needed, not before. Its opposite, working everything out up front, is eager evaluation, which is what a list comprehension does. Lazy evaluation gives generators two powers a list cannot have.

Endless sequences

Because a generator only works out the values it is asked for, its body can loop forever with while True:. Each next() runs one pass and pauses at the yield; values nobody asks for are never made.

To take only the first few values of an endless generator, use islice from the standard library module itertools (tools for iterators). islice(iterator, n) gives the first n items of an iterator, lazily, like slicing [:n] on a list. A list slice does not work here, because a generator cannot be indexed (Day 11).

Worked example: Ticket numbers that never run out

ticket_numbers.py
from itertools import islice


def ticket_numbers(start):
    number = start
    while True:
        yield f"T-{number}"
        number += 1


tickets = ticket_numbers(100)
print(next(tickets))
print(next(tickets))
print(list(islice(tickets, 3)))
print(next(tickets))
T-100
T-101
['T-102', 'T-103', 'T-104']
T-105
  • Line 11 makes the generator; the endless loop has not started.
  • Lines 12 and 13: each next runs one pass of the while loop, yields one ticket, and pauses. number is kept between calls, so it goes 100, 101.
  • Line 14: islice(tickets, 3) asks for the next three values only, and list() collects them. The generator then sits paused, ready for more.
  • Line 15 carries on from the bookmark with T-105.

Never pass an endless generator straight to something that wants every value, such as list(ticket_numbers(100)), sum, or sorted. They keep asking for values forever, using more and more memory, until you press Ctrl+C. Take a limited number first with islice, or stop a for loop with break (Day 5).

Files bigger than memory

A server writes a line to its log every time something happens, and a busy server’s log can be gigabytes long, far more than a program should load with read() or readlines() (Day 14). Day 14 showed that for line in file: reads one line at a time. Generators let you build several processing steps on top of that, and keep the one-line-at-a-time property.

Save this as server_log.txt in day18:

INFO server started
INFO user ana signed in
ERROR disk almost full
INFO user ben signed in
ERROR payment service timed out
INFO backup finished
ERROR disk almost full

Worked example: A pipeline of generators

log_pipeline.py
def error_lines(lines):
    for line in lines:
        if line.startswith("ERROR"):
            yield line.strip()


def messages(lines):
    for line in lines:
        yield line[6:]


with open("server_log.txt", encoding="utf-8") as file:
    errors = messages(error_lines(file))
    for number, message in enumerate(errors, 1):
        print(number, message)
1 disk almost full
2 payment service timed out
3 disk almost full

Each generator function takes an iterable of lines and yields lines. error_lines passes on only the lines that start with ERROR, stripped of their "\n"; messages cuts off the first six characters, "ERROR ", with a Day 3 slice. Line 13 connects them: the file feeds error_lines, which feeds messages. Chained generators like this are called a pipeline, because each value flows through every step in turn, like water through pipes.

Line 13 itself does no work at all; it only connects three iterators. The work happens when line 14’s loop asks for a value:

The loop asks messages messages asks error_lines error_lines asks the file Result
1st value first error line reads 3 lines; the 3rd is ERROR disk almost full
2nd value next error line reads 2 more lines payment service timed out
3rd value next error line reads 2 more lines disk almost full
4th value next error line reaches the end of the file StopIteration; loop ends

At any moment, only one line of the file is in memory. The same program would work, unchanged, on a log of ten million lines.

Key idea

Lazy evaluation means working out each value only when it is asked for. It lets a generator be endless (take what you need with itertools.islice), and it lets a pipeline of generators handle a huge file one line at a time.

Watch out

Using a file’s generator after the with block. Because nothing is read until a value is asked for, this program has read nothing when the with block closes the file (Day 14):

log_closed.py
def error_lines(lines):
    for line in lines:
        if line.startswith("ERROR"):
            yield line.strip()


with open("server_log.txt", encoding="utf-8") as file:
    errors = error_lines(file)

for line in errors:
    print(line)
Traceback (most recent call last):
  File ".../day18/log_closed.py", line 10, in <module>
    for line in errors:
                ^^^^^^
  File ".../day18/log_closed.py", line 2, in error_lines
    for line in lines:
                ^^^^^
ValueError: I/O operation on closed file.

Line 8 only creates the generator. The first real read happens on line 10, after the file is closed. Keep the loop inside the with block, as log_pipeline.py does, or open the file inside the generator function itself, so the file stays open exactly as long as the generator is being used.

Pause and try

Question 6 · Tracing

What does this program print?

lazy_trace.py
from itertools import islice


def multiples(step):
    total = 0
    while True:
        total += step
        yield total


fives = multiples(5)
print(next(fives))
print(list(islice(fives, 2)))
print(next(fives) + next(fives))
Check your reasoning
5
[10, 15]
45

Each next runs one pass of the endless loop: add 5 to total, yield it, pause.

Line Asks for total values yielded Printed
12 1 value 5 5
13 2 values (islice) 10, 15 [10, 15]
14 2 values 20, 25 20 + 25 = 45

The generator is never restarted: each line carries on from where the previous one paused. The program ends normally even though the loop is endless, because nobody asks for a sixth value.

Reread 18.4 Lazy Evaluation: Endless and Large Inputs if your output differed.

Question 7 · Short answer

A classmate rewrites log_pipeline.py so that error_lines builds a list of every error line and returns it, and messages does the same. The output does not change. What does change, and when would it matter?

Check your reasoning

Model answer: with lists, the work becomes eager. error_lines reads the whole file before returning, and holds every error line in memory at once; messages then builds a second complete list. Only after both lists exist does the loop print anything. With generators, each line flows through all the steps and is printed before the next line is read, so only one line is in memory at a time.

For a seven-line file, there is no visible difference. For a log of millions of lines, the list version may use a large amount of memory (or run out), and it shows nothing until the whole file has been read; the generator version uses almost no memory and starts printing straight away.

A correct answer mentions that the list version holds all the lines at once while the generator version holds one at a time, and that this matters for very large inputs.

Reread 18.4 Lazy Evaluation: Endless and Large Inputs if your reasoning differed.

Section 5 of 8

18.5 Closures: Functions That Remember

A teacher wants several test functions for scores: “at least 60” to pass, “at least 80” for honors, “at least 90” for a prize. Writing is_passing, is_honors, and is_prize repeats the same line with a different number each time. It would be better to have one function that makes the others: you tell it the limit, and it hands back a ready-made test.

Day 10 showed that functions are values: you can pass a function as an argument, and a function can also return one. And Day 9 said that a function’s local variables are thrown away when the call ends. Today’s idea seems to break that rule, so read carefully.

You can write a def inside another function’s body. The inner function is a nested function, and it exists only inside the outer function, like any other local variable. A nested function can read the outer function’s local variables. When Python looks up a name inside the nested function, it searches the nested function’s own locals first, then the enclosing function’s locals, then the globals (Day 9), then the built-in names such as print.

Worked example: A function that makes multiplying functions

make_multiplier.py
def make_multiplier(factor):
    def multiply(number):
        return number * factor

    return multiply


double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10), triple(10))
print(double(7))
20 30
14
Step What happens
1 Line 8 calls make_multiplier(2). Inside, factor is 2, and lines 2–3 make a new function multiply
2 Line 5 returns multiply, the function itself (no parentheses), and line 8 names it double
3 The call to make_multiplier is over, but double still remembers that factor is 2
4 Line 9 does the same with 3: a second, separate multiply, which remembers factor = 3, named triple
5 Line 10: double(10) runs number * factor = 10 × 2 = 20; triple(10) is 10 × 3 = 30
6 Line 11: double(7) still uses its own factor: 14

Step 3 is the new idea. Normally factor would be thrown away when make_multiplier returns. But the returned function uses factor, so Python keeps that variable alive, attached to the function. A function that remembers variables from the enclosing function it was made in, even after that function has returned, is called a closure. Think of a closure as a function carrying a small backpack: the backpack holds the outer variables it uses, and every call can look inside it. Each call to make_multiplier packs a new backpack, which is why double and triple do not interfere.

Here is the teacher’s problem solved with a closure. The made functions are passed around like any other function value:

at_least.py
def at_least(limit):
    def check(score):
        return score >= limit

    return check


scores = [55, 72, 90, 64, 81]
passing = at_least(60)
honors = at_least(80)
print([score for score in scores if passing(score)])
print([score for score in scores if honors(score)])
print(passing(59), honors(80))
[72, 90, 64, 81]
[90, 81]
False True

at_least(60) returns a check function that remembers limit = 60, and at_least(80) returns another that remembers 80. Each comprehension (Day 11) calls its own test on every score. A prize test is now one more line, prize = at_least(90), not one more function.

Key idea

A function defined inside another function can read the outer function’s variables. When the outer function returns the inner one, the inner function is a closure: it keeps those variables alive and uses them on every call. Each call of the outer function makes a new closure with its own variables.

Watch out

Returning the inner function’s result instead of the function. Parentheses after multiply call it (Day 10):

return_called.py
def make_multiplier(factor):
    def multiply(number):
        return number * factor

    return multiply()


double = make_multiplier(2)
print(double(10))
Traceback (most recent call last):
  File ".../day18/return_called.py", line 8, in <module>
    double = make_multiplier(2)
  File ".../day18/return_called.py", line 5, in make_multiplier
    return multiply()
           ~~~~~~~~^^
TypeError: make_multiplier.<locals>.multiply() missing 1 required positional argument: 'number'

make_multiplier.<locals>.multiply is Python’s full name for “the function multiply defined inside make_multiplier”. Line 5 tries to run multiply straight away, with no number, which fails. Even with a number, the outer function would return a result, not a function to keep. Return the function itself: return multiply.

Pause and try

Question 8 · Tracing

What does this program print? The last line is the tricky one.

greeter_trace.py
def make_greeter(greeting):
    punctuation = "!"

    def greet(name):
        return f"{greeting}, {name}{punctuation}"

    return greet


hello = make_greeter("Hello")
hola = make_greeter("Hola")
print(hello("Ana"))
print(hola("Ben"))
print(hello(hola("Cy")))
Check your reasoning
Hello, Ana!
Hola, Ben!
Hello, Hola, Cy!!

hello is a closure remembering greeting = "Hello" and punctuation = "!"; hola is a separate closure remembering "Hola" and its own "!". Line 14 works from the inside out, as on Day 10: hola("Cy") gives "Hola, Cy!", and that whole string is then the name passed to hello, which puts "Hello, " in front and another "!" at the end.

Reread 18.5 Closures: Functions That Remember if your output differed.

Question 9 · Multiple choice

After double = make_multiplier(2) has finished, why does double(10) still know that factor is 2?

  1. factor became a global variable when make_multiplier ran
  2. double is a closure, which keeps the variables it uses from the function that made it
  3. Python stores the last argument of every function call
  4. double(10) calls make_multiplier again
Check your reasoning

B. The inner function uses factor, so Python keeps that variable alive with the function after the outer call returns. That is what makes it a closure.

  • A is wrong: factor is a parameter, so it is local to make_multiplier. If it were global, triple = make_multiplier(3) would change it for double too, and double(10) would give 30.
  • C is wrong: Python keeps no such record. The variable survives only because a closure uses it.
  • D is wrong: double is the inner multiply function. Calling it runs only return number * factor.

Reread 18.5 Closures: Functions That Remember if you chose differently.

Section 6 of 8

18.6 Changing a Remembered Variable with nonlocal

A web page wants to count its clicks. A closure can remember a count between calls, so the plan is a function make_counter that returns an increment function, where each call adds 1 to the remembered count and returns it. The first try fails:

counter_no_nonlocal.py
def make_counter():
    count = 0

    def increment():
        count += 1
        return count

    return increment


clicks = make_counter()
print(clicks())
Traceback (most recent call last):
  File ".../day18/counter_no_nonlocal.py", line 12, in <module>
    print(clicks())
          ~~~~~~^^
  File ".../day18/counter_no_nonlocal.py", line 5, in increment
    count += 1
    ^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

You have seen this error before, on Day 9, with a global variable. The rule is the same: a name that is assigned anywhere in a function is local to that function. count += 1 assigns to count, so inside increment, count is a brand-new local variable, not the enclosing one, and it has no value yet when += 1 reads it. A closure can read an enclosing variable freely, but assigning to it makes a local instead.

Day 9’s fix for a global was the global statement. For an enclosing function’s variable, the statement is nonlocal: nonlocal count inside the nested function means “count here is the variable of the enclosing function, not a new local”. Then assigning to it changes the remembered variable.

Worked example: Independent counters

counter_closure.py
def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment


clicks = make_counter()
views = make_counter()
print(clicks(), clicks(), clicks())
print(views())
print(clicks())
1 2 3
1
4
Line Call clicks’ count views’ count Returns
12 (made) 0
13 (made) 0 0
14 clicks() ×3 1, 2, 3 0 1, 2, 3
15 views() 3 1 1
16 clicks() 4 1 4

Each make_counter() call makes a separate count, so the two counters never mix. Nothing outside can change a counter’s count except by calling it, which is the kind of protection Day 16 used a class for. A closure with nonlocal is like a tiny object with one method.

Do you always need nonlocal to change something remembered? Only when you assign to the name. Changing a remembered list in place, with append, assigns nothing, so it needs no nonlocal, just as a function could append to a list passed in on Day 9.

Inside the nested function Needs nonlocal? Why
return number * factor No Only reads factor
count += 1 or count = count + 1 Yes Assigns to count, which would otherwise be local
history.append(item) No Changes the list; the name history is not assigned

Key idea

A closure can read enclosing variables, but assigning to one makes a new local variable instead. Write nonlocal name in the nested function to assign to the enclosing function’s variable, the way global does for a global.

Watch out

Using global instead of nonlocal. global count makes Python look for count among the file’s global variables, not in make_counter:

counter_global.py
def make_counter():
    count = 0

    def increment():
        global count
        count += 1
        return count

    return increment


clicks = make_counter()
print(clicks())
Traceback (most recent call last):
  File ".../day18/counter_global.py", line 13, in <module>
    print(clicks())
          ~~~~~~^^
  File ".../day18/counter_global.py", line 6, in increment
    count += 1
    ^^^^^
NameError: name 'count' is not defined. Did you mean: 'round'?

There is no global count, only the one inside make_counter, so Python finds nothing. (Its guess, round, is just the built-in name spelled most like count; ignore it.) If the file did have a global count, every counter would share it, and the separate counters would be lost. A variable that belongs to an enclosing function needs nonlocal.

Pause and try

Question 10 · Debugging

This program should keep a running total: it should print 5, then 8. It crashes. Explain the error and fix it.

running_total.py
def make_running_total():
    total = 0

    def add(amount):
        total = total + amount
        return total

    return add


till = make_running_total()
print(till(5))
print(till(3))
Check your reasoning

The traceback ends with:

UnboundLocalError: cannot access local variable 'total' where it is not associated with a value

Line 5 assigns to total, so total is local to add, and the right side, total + amount, reads that local before it has a value. The enclosing total on line 2 is never used. Declare that total belongs to the enclosing function:

running_total_fixed.py
def make_running_total():
    total = 0

    def add(amount):
        nonlocal total
        total = total + amount
        return total

    return add


till = make_running_total()
print(till(5))
print(till(3))
5
8

Now line 6 changes the remembered total: 0 + 5 = 5, then 5 + 3 = 8.

Reread 18.6 Changing a Remembered Variable with nonlocal if you missed it.

Question 11 · Tracing

What does this program print? Notice that there is no nonlocal.

history_trace.py
def make_recorder():
    history = []

    def record(item):
        history.append(item)
        return len(history)

    return record


songs = make_recorder()
games = make_recorder()
print(songs("Help"), songs("Yellow"))
print(games("chess"))
print(songs("Rain"))
Check your reasoning
1 2
1
3

append changes the remembered list without assigning to history, so no nonlocal is needed and there is no error. songs and games each have their own history list. songs gets "Help" (length 1) and "Yellow" (length 2); games gets "chess" in its own list (length 1); songs then gets "Rain", its third item.

Reread 18.6 Changing a Remembered Variable with nonlocal if your output differed.

Section 7 of 8

18.7 Decorators: Functions That Wrap Functions

While testing a program, you want every call of certain functions to print a line saying which function is running. You could add two print lines to each function, but that changes code that already works, repeats itself, and has to be undone afterwards. It would be better to keep each function exactly as it is and wrap it: make a new function that prints the message, calls the original, and passes its result back.

You now have everything this needs. A function can take a function as an argument (Day 10). A nested function can remember a variable from the function that made it (18.5), including a parameter holding a function. And *args and **kwargs (Day 10) let one function accept any arguments at all and pass them on unchanged.

One more fact: every function has an attribute __name__ that holds the name it was defined with, just as every module has a __name__ (Day 13). For def add(a, b):, add.__name__ is "add".

A decorator is a function that takes a function and returns a replacement for it, usually a wrapper: a nested function that does some extra work and calls the original. Here is one, applied by hand:

Worked example: Wrapping a function by hand

log_by_hand.py
def log_calls(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        result = func(*args, **kwargs)
        print("Finished", func.__name__)
        return result

    return wrapper


def add(a, b):
    return a + b


add = log_calls(add)
print(add(2, 3))
Calling add
Finished add
5
  • Line 1: log_calls takes a function, func.
  • Lines 2 to 6 define the wrapper. *args, **kwargs collect whatever arguments the wrapper is called with, and line 4 spreads them back out into the call func(*args, **kwargs), so the original gets exactly the arguments the caller gave. Line 6 passes the original’s result back.
  • Line 8 returns the wrapper, which is a closure: it remembers func.
  • Line 15 is the key line. log_calls(add) returns a wrapper around the original add, and the name add is pointed at the wrapper.
  • Line 16 calls add(2, 3), which now means the wrapper: args is (2, 3). It prints Calling add, calls the original add(2, 3), which returns 5, prints Finished add, and returns 5, which print shows.

Line 15’s pattern, name = decorator(name) right after the def, is so common that Python has a short form. Writing @log_calls on the line directly above def add means exactly “define add, then do add = log_calls(add)”. This is the @ you met on Day 17: @property above a method is area = property(area), with a decorator that comes with Python. The @ line runs once, when the def runs, not each time the function is called.

log_calls.py
def log_calls(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        result = func(*args, **kwargs)
        print("Finished", func.__name__)
        return result

    return wrapper


@log_calls
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"


@log_calls
def area(width, height):
    return width * height


print(greet("Ana"))
print(greet("Ben", greeting="Hi"))
print(area(4, 3))
Calling greet
Finished greet
Hello, Ana!
Calling greet
Finished greet
Hi, Ben!
Calling area
Finished area
12

One decorator, written once, wraps two quite different functions. greet("Ben", greeting="Hi") arrives in the wrapper as args = ("Ben",) and kwargs = {"greeting": "Hi"}, and line 4 passes both on, so the original greet gets its keyword argument. Because the wrapper accepts anything, it fits any function. To stop wrapping a function, delete the @ line; the function’s own code never changed.

A wrapper can also change the result instead of just reporting. This decorator rounds whatever the wrapped function returns:

two_decimals.py
def two_decimals(func):
    def wrapper(*args, **kwargs):
        return round(func(*args, **kwargs), 2)

    return wrapper


@two_decimals
def average(*scores):
    return sum(scores) / len(scores)


@two_decimals
def with_tax(price, rate=0.08):
    return price * (1 + rate)


print(average(85, 90, 78))
print(with_tax(19.99))
print(with_tax(5, rate=0.2))
84.33
21.59
6.0

average(85, 90, 78) is 84.333…, which the wrapper rounds to 84.33 with Day 2’s round. with_tax(19.99) is 21.5892, rounded to 21.59. Each function stays focused on its own job, and the rounding rule lives in one place. Programmers use decorators like this for logging, for checking arguments, for counting calls, and for timing how long a function takes, which is today’s third practice exercise.

Key idea

A decorator takes a function and returns a wrapper that does extra work around a call to the original. Give the wrapper *args, **kwargs so it accepts and passes on any arguments, and make it return the original’s result. @decorator above a def means name = decorator(name), done once when the function is defined.

Watch out

Forgetting to return the result. If the wrapper calls the original but does not return what it got back, the wrapper returns None (Day 9), and the result is lost:

forgot_return.py
def log_calls(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        func(*args, **kwargs)

    return wrapper


@log_calls
def area(width, height):
    return width * height


print(area(4, 3))
Calling area
None

area(4, 3) worked out 12, but the wrapper dropped it. Every function a decorator wraps would now return None. Store the result and return it, as on lines 4 and 6 of log_calls.py, or write return func(*args, **kwargs).

Pause and try

Question 12 · Tracing

What does this program print?

fence_trace.py
def fence(func):
    def wrapper(*args, **kwargs):
        print("+-----+")
        result = func(*args, **kwargs)
        print("+-----+")
        return result

    return wrapper


@fence
def show(word):
    print("|", word)
    return len(word)


size = show("hi")
print(size)
Check your reasoning
+-----+
| hi
+-----+
2

@fence replaced show with the wrapper. Line 17 calls the wrapper with args = ("hi",): it prints the top line, calls the original show("hi"), which prints | hi and returns len("hi") = 2, then prints the bottom line and returns 2. Line 18 prints that 2. The fence lines come from the wrapper, around the original’s own output.

Reread 18.7 Decorators: Functions That Wrap Functions if your output differed.

Question 13 · Multiple choice

Which code does exactly what @two_decimals above def total(prices): does?

  1. total = two_decimals(total), after the def
  2. total = two_decimals(total()), after the def
  3. two_decimals = total(two_decimals), after the def
  4. Calling two_decimals(total) inside every call to total
Check your reasoning

A. The @ line passes the newly defined function to the decorator and puts the name back on whatever the decorator returns: the wrapper.

  • B is wrong: total() calls the function (with no prices, so it fails) and would pass its result, not the function, to the decorator.
  • C is wrong: it has the roles backwards and replaces the decorator’s name.
  • D is wrong: decorating happens once, when the def runs. Each call to total runs the wrapper, which already exists.

Reread 18.7 Decorators: Functions That Wrap Functions if you chose differently.

Section 8 of 8

18.8 Keeping a Function’s Name with functools.wraps

Decorating has a side effect you have not seen yet. After @log_calls, the name area refers to the wrapper, so everything Python can tell you about area now describes the wrapper instead. Day 9’s help() and docstrings show the problem. A function’s docstring is stored in its attribute __doc__:

name_lost.py
def log_calls(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)

    return wrapper


@log_calls
def area(width, height):
    """Return the area of a rectangle."""
    return width * height


print(area.__name__)
print(area.__doc__)
help(area)
wrapper
None
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

The function’s name says wrapper, its docstring is gone (None), and help() shows parameters that tell a user nothing about how to call area. Tracebacks, help(), and debugging and testing tools would all report wrapper for every function this decorator touches, which makes them much harder to tell apart.

The standard library’s functools module (tools for functions) has the fix. functools.wraps(func) is a decorator you put on the wrapper, inside your decorator. It copies the original function’s name, docstring, and parameter details onto the wrapper, so the wrapper presents itself as the function it wraps.

Worked example: A decorator that keeps the original's identity

name_kept.py
from functools import wraps


def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)

    return wrapper


@log_calls
def area(width, height):
    """Return the area of a rectangle."""
    return width * height


print(area.__name__)
print(area.__doc__)
help(area)
print(area(4, 3))
area
Return the area of a rectangle.
Help on function area in module __main__:

area(width, height)
    Return the area of a rectangle.

Calling area
12
  • Line 1 imports wraps with Day 13’s from ... import.
  • Line 5 puts @wraps(func) directly above def wrapper. Unlike @property, this decorator line has parentheses and an argument: wraps(func) is a call that returns a decorator which remembers func (a closure, 18.5), and that decorator is then applied to wrapper.
  • Lines 19 to 21 now report area, its docstring, and its real parameters, width and height.
  • Line 22 shows the wrapper still works exactly as before: only the labels changed.

With wraps, here is the shape of nearly every decorator you will write. Copy it, then fill in the extra work:

from functools import wraps


def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # work before the call
        result = func(*args, **kwargs)
        # work after the call
        return result

    return wrapper

Key idea

A wrapper hides the original function’s name and docstring. Put @wraps(func), from functools, directly above def wrapper in every decorator you write, so the decorated function keeps its own name, docstring, and parameters.

Watch out

Putting @wraps(func) on the decorator instead of the wrapper. func exists only inside the decorator, as its parameter:

wraps_outside.py
from functools import wraps


@wraps(func)
def log_calls(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)

    return wrapper
Traceback (most recent call last):
  File ".../day18/wraps_outside.py", line 4, in <module>
    @wraps(func)
           ^^^^
NameError: name 'func' is not defined

Line 4 runs when the def log_calls line is reached, and no variable called func exists out there. @wraps(func) belongs one level in, directly above def wrapper, where func is the parameter it should copy from.

Pause and try

Question 14 · Tracing

What does this program print?

shout_names.py
from functools import wraps


def plain(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)

    return wrapper


def careful(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)

    return wrapper


@plain
def shout(text):
    """Return text in capitals."""
    return text.upper()


@careful
def whisper(text):
    """Return text in small letters."""
    return text.lower()


print(shout("hi"), shout.__name__)
print(whisper("HI"), whisper.__name__, whisper.__doc__)
Check your reasoning
HI wrapper
hi whisper Return text in small letters.

Both decorators make a wrapper that passes the call through, so shout("hi") and whisper("HI") work normally. plain does not use wraps, so shout now names a function called wrapper. careful uses @wraps(func), which copies the name whisper and its docstring onto its wrapper.

Reread 18.8 Keeping a Function’s Name with functools.wraps if your output differed.

Wrap-up · 1 of 8

Putting it together

A fitness tracker writes one line per day to a file, as date,steps. Lines starting with # are notes. Save this as steps.txt in day18:

# date,steps
2026-10-01,6210
2026-10-02,10432
# watch battery died
2026-10-03,3105
2026-10-04,12877
2026-10-05,9050
2026-10-06,11240

The program below reads it lazily, uses closures for the step goals, and a decorator to label each report.

step_tracker.py
from functools import wraps


def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[{func.__name__}]")
        return func(*args, **kwargs)

    return wrapper


def read_days(path):
    with open(path, encoding="utf-8") as file:
        for line in file:
            if line.startswith("#"):
                continue
            date, steps = line.strip().split(",")
            yield date, int(steps)


def at_least(goal):
    def reached(day):
        return day[1] >= goal

    return reached


@log_calls
def report(days, test):
    count = 0
    for day in days:
        if test(day):
            count += 1
            print(f"  {day[0]}: {day[1]}")
    return count


days = read_days("steps.txt")
first_date, first_steps = next(days)
print("First day:", first_date, first_steps)

total = report(days, at_least(10000))
print("Goal reached on", total, "of the remaining days")

busy = report(read_days("steps.txt"), at_least(12000))
print("Very busy days:", busy)
First day: 2026-10-01 6210
[report]
  2026-10-02: 10432
  2026-10-04: 12877
  2026-10-06: 11240
Goal reached on 3 of the remaining days
[report]
  2026-10-04: 12877
Very busy days: 1
Lines What happens Idea
4–10 A decorator with @wraps, so the label prints the real name, report, not wrapper 18.7, 18.8
13–19 A generator function that opens the file itself, skips notes, and yields one (date, steps) tuple per day 18.3, 18.4
22–26 at_least(goal) returns a closure that remembers goal 18.5
29–36 report loops over any iterable of days and uses whatever test it is given 18.1
39–41 next(days) takes the first day off the generator by hand 18.1
43 The same generator carries on from its bookmark, so 2026-10-01 is not counted again 18.1, 18.4
46 A new generator reads the file from the start for the second report 18.3

Follow the data. Line 39 runs no code in read_days. Line 40’s next runs the body: it opens the file, skips the note on the first line, and pauses at yield with the first day. Line 43’s report then pulls the remaining five days through the same paused generator. Of those, 10432, 12877, and 11240 reach 10000, so the count is 3. When the loop has the last line, the generator’s body finishes, which ends its with block and closes the file. Line 46 needs every day again, so it calls read_days again for a fresh generator; reusing days would find nothing left. Only one line of the file is in memory at any moment, so the same program would work on years of data.

Wrap-up · 2 of 8

Common mistakes

Several of today’s errors appear far from the line that caused them. Use this table to go from a message to its usual cause.

Message or symptom Usual cause Fix
TypeError: 'list' object is not an iterator Called next() on an iterable Call iter() first
StopIteration traceback Called next() on a finished iterator Give a default: next(it, None)
TypeError: 'Countdown' object is not iterable A class has __next__ but no __iter__ Add __iter__ that returns self
A second loop over a generator does nothing The generator is used up Call the generator function again
Only the first value comes back return where yield was meant Use yield
The program never ends list(), sum(), or sorted() on an endless generator Take a few with islice
ValueError: I/O operation on closed file. A file’s generator was used after its with block Loop inside the block, or open in the generator
TypeError: make_multiplier.<locals>.multiply() missing 1 required ... return multiply() instead of return multiply Return the function, no parentheses
UnboundLocalError: cannot access local variable 'count' ... A closure assigns to an enclosing variable Add nonlocal count
A decorated function returns None The wrapper does not return the result return func(*args, **kwargs)
TypeError: log_calls.<locals>.wrapper() takes 1 positional argument ... The wrapper does not accept every argument def wrapper(*args, **kwargs):
__name__ is wrapper; help() shows wrapper(*args, **kwargs) The decorator does not use wraps @wraps(func) above def wrapper
NameError: name 'func' is not defined on an @wraps line @wraps(func) above the decorator instead of the wrapper Move it above def wrapper

Older versions of Python may word some of these messages differently.

Wrap-up · 3 of 8

Vocabulary

Term Definition Example
iterator An object that hands out items one at a time with next() and remembers which comes next iter(colors)
iter() Returns an iterator for an iterable bookmark = iter(scores)
next() Returns an iterator’s next item, or a default if one is given and the iterator is finished next(bookmark, "no more")
StopIteration The exception an iterator raises when it has no items left raise StopIteration
iteration protocol The agreement: iter() gives an iterator, and next() gives items until StopIteration what every for loop does
protocol An agreed way of doing something the iteration protocol
__iter__ / __next__ The special methods iter() and next() call def __next__(self):
generator function A function containing yield; calling it returns a generator without running the body def countdown(start):
yield Hands a value to next() and pauses the generator, keeping its local variables yield current
lazy evaluation Working out each value only when it is asked for a generator
eager evaluation Working out every value up front a list comprehension
itertools.islice() Gives the first n items of an iterator, lazily islice(tickets, 3)
pipeline Generators chained so each value flows through every step in turn messages(error_lines(file))
nested function A function defined inside another function’s body def multiply(number): inside make_multiplier
closure A nested function that keeps the enclosing function’s variables after that function returns double = make_multiplier(2)
nonlocal Says that a name in a nested function is the enclosing function’s variable, so it can be assigned nonlocal count
decorator A function that takes a function and returns a replacement, usually a wrapper log_calls
wrapper The nested function a decorator returns, which does extra work around a call to the original def wrapper(*args, **kwargs):
__name__ (of a function) The name a function was defined with add.__name__ is "add"
__doc__ A function’s docstring area.__doc__
functools.wraps() A decorator for a wrapper that copies the original’s name, docstring, and parameters onto it @wraps(func)
Wrap-up · 4 of 8

Summary

  • A for loop follows the iteration protocol: iter() gets an iterator, and next() asks it for items until it raises StopIteration. A list hands out a fresh iterator each time; a generator or a file is its own iterator, so it can be used only once. A class becomes an iterator with __iter__ and __next__.
  • A function containing yield is a generator function. Calling it runs nothing and returns a generator; each next() runs the body to the next yield, hands over that value, and pauses with its local variables kept. return ends a function with one value; yield hands over a value and waits to continue.
  • Generators are lazy: they make each value only when asked. That allows endless generators (take what you need with islice) and pipelines that handle huge files one line at a time.
  • A closure is a nested function that remembers the variables of the function that made it. It can read them freely; to assign to one, declare it nonlocal.
  • A decorator takes a function and returns a wrapper that adds work around a call to the original. The wrapper takes *args, **kwargs and returns the result, @decorator above a def applies it once, and @wraps(func) keeps the original’s name and docstring.
Wrap-up · 5 of 8

Practice

Do these in your day18 folder. Type each program yourself, run it, and only then open the answer.

Question 15 · Short answer

Endless Fibonacci. The Fibonacci numbers start 0, 1, and each one after that is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8, … Write a generator function that yields Fibonacci numbers forever, and use it to make a list of the first 20.

Check your reasoning

One possible solution:

fibonacci.py
from itertools import islice


def fibonacci():
    current, following = 0, 1
    while True:
        yield current
        current, following = following, current + following


first_20 = list(islice(fibonacci(), 20))
print(first_20)
print(len(first_20), first_20[-1])
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181]
20 4181

How it works: the generator keeps only the two most recent numbers, current and following. Each pass yields current and then moves both along at once with Day 7’s tuple swap: the new current is the old following, and the new following is their sum. Because the swap happens in one line, neither value is overwritten before it is used. while True: makes the generator endless (18.4), which is safe because islice(fibonacci(), 20) asks for exactly 20 values. Calling list(fibonacci()) without islice would never finish.

Reread 18.3 Generator Functions and yield and 18.4 Lazy Evaluation: Endless and Large Inputs if your result differed.

Question 16 · Short answer

Skipping blank lines. Write a generator function that takes a file path, reads the file, and yields only the lines that are not blank, without their "\n". A line holding only spaces counts as blank. Test it on a notes file with some blank lines, numbering the lines it gives back.

Check your reasoning

Save this as notes.txt. Spaces do not show up below, so to test the all-spaces case, type three spaces on line 3, and two spaces before and after the last line:

Buy milk


Call Ana about the project


Water the plants
  Return library books

One possible solution:

non_blank.py
def non_blank_lines(path):
    with open(path, encoding="utf-8") as file:
        for line in file:
            text = line.strip()
            if text:
                yield text


for number, line in enumerate(non_blank_lines("notes.txt"), 1):
    print(f"{number}. {line}")

print(sum(1 for _ in non_blank_lines("notes.txt")))
1. Buy milk
2. Call Ana about the project
3. Water the plants
4. Return library books
4

How it works: the generator opens the file itself, inside its own with block, so the file is open exactly while the lines are being used and is closed when the generator finishes (18.4). line.strip() removes the "\n" and any spaces, so a blank or all-spaces line becomes "", which is falsy (Day 4) and is skipped. Only one line is in memory at a time, so this works on any size of file. The last line counts the non-blank lines with Day 11’s sum over a generator expression; it calls non_blank_lines again, because the first generator was used up by the for loop.

Reread 18.4 Lazy Evaluation: Endless and Large Inputs if your result differed.

Question 17 · Short answer

A timer decorator. Write a @timer decorator that prints how long each call to the decorated function took. Use the standard library’s time module: time.perf_counter() returns a number of seconds as a float, and the difference between two readings is how long the code between them took. time.sleep(seconds) pauses a program, which makes a handy slow function to test with. Keep the decorated function’s name and docstring.

Check your reasoning

One possible solution:

import time
from functools import wraps


def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result

    return wrapper


@timer
def sum_of_squares(limit):
    """Add up the squares of 1 to limit."""
    return sum(n * n for n in range(1, limit + 1))


@timer
def slow_hello(name, seconds=0.5):
    time.sleep(seconds)
    return f"Hello, {name}!"


print(sum_of_squares(1000000))
print(slow_hello("Ana"))
print(sum_of_squares.__name__, "-", sum_of_squares.__doc__)

The times change on every run and on every computer, so your numbers will differ. The output has five lines. First comes a line such as sum_of_squares took 0.0213 seconds, then the sum itself, 333333833333500000 (the same total as Day 11’s big sum). Next, slow_hello took a little over 0.5 seconds, then Hello, Ana!. The last line is sum_of_squares - Add up the squares of 1 to limit.

How it works: the wrapper reads the clock, calls the original with all its arguments (18.7), reads the clock again, and prints the difference with Day 3’s :.4f. It prints before the caller’s print, because the wrapper finishes, timing line and all, before its return value reaches print. It returns the result, so the decorated functions still give their answers. @wraps(func) keeps the name and docstring (18.8), which is why the last line shows sum_of_squares and its docstring, not wrapper and None.

Reread 18.7 Decorators: Functions That Wrap Functions and 18.8 Keeping a Function’s Name with functools.wraps if your result differed.

Wrap-up · 6 of 8

Review questions

Question 18 · Tracing

What does this program print?

numbered_trace.py
def numbered(items, start=1):
    number = start
    for item in items:
        yield f"{number}. {item}"
        number += 1


chores = numbered(["wash", "dry", "fold"])
print(next(chores))
for line in chores:
    print(line.upper())
print(list(chores))
print(list(numbered("ab", start=5)))
Check your reasoning
1. wash
2. DRY
3. FOLD
[]
['5. a', '6. b']
Line What asks Generator yields (and pauses) Printed
9 next(chores) "1. wash"; number becomes 2 on resume 1. wash
10–11 the for loop "2. dry", "3. fold", then the body ends 2. DRY, 3. FOLD
12 list(chores) nothing: the generator is used up []
13 a new generator "5. a", "6. b" (a string is iterable) the list

The for loop carries on from the same bookmark as next, so wash is not repeated, and upper() changes the whole string, including dry. Line 13 makes a fresh generator with start=5.

Reread 18.1 What a for Loop Really Does: iter() and next() and 18.3 Generator Functions and yield if your output differed.

Question 19 · Tracing

What does this program print? This decorator returns the original function instead of a wrapper.

register_trace.py
def register(func):
    print("registering", func.__name__)
    return func


@register
def hello():
    print("hello")


print("program starts")
hello()
hello()
Check your reasoning
registering hello
program starts
hello
hello

The @register line runs hello = register(hello) once, as soon as the def has run, which is before line 11. So registering hello prints first. register returns func itself, so hello is still the original function, and each call prints only hello; the registering message does not repeat. A decorator does not have to make a wrapper: real programs use this shape to keep a list of functions (for example, every menu command) without changing them.

Reread 18.7 Decorators: Functions That Wrap Functions if your output differed.

Question 20 · Debugging

This decorator works on double but crashes on add. Explain the error and fix it.

one_arg_wrapper.py
def log_calls(func):
    def wrapper(value):
        print("Calling", func.__name__)
        return func(value)

    return wrapper


@log_calls
def double(number):
    return number * 2


@log_calls
def add(a, b):
    return a + b


print(double(4))
print(add(2, 3))
Check your reasoning

It prints Calling double and 8, then stops with:

TypeError: log_calls.<locals>.wrapper() takes 1 positional argument but 2 were given

After decorating, add is the wrapper, and the wrapper has exactly one parameter, value. add(2, 3) passes two arguments to it, so the call fails before the original add even runs. The message names log_calls.<locals>.wrapper, the function defined inside log_calls, which is the clue that the problem is in the decorator, not in add. Let the wrapper accept and pass on any arguments:

one_arg_wrapper_fixed.py
def log_calls(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)

    return wrapper


@log_calls
def double(number):
    return number * 2


@log_calls
def add(a, b):
    return a + b


print(double(4))
print(add(2, 3))
Calling double
8
Calling add
5

Reread 18.7 Decorators: Functions That Wrap Functions if you missed it.

Question 21 · Tracing

What does this program print? The decorator uses a closure with nonlocal.

count_calls.py
from functools import wraps


def count_calls(func):
    calls = 0

    @wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal calls
        calls += 1
        print(f"{func.__name__} call {calls}")
        return func(*args, **kwargs)

    return wrapper


@count_calls
def square(n):
    return n * n


@count_calls
def cube(n):
    return n ** 3


print(square(3))
print(square(4))
print(cube(2))
print(square(5))
Check your reasoning
square call 1
9
square call 2
16
cube call 1
8
square call 3
25

@count_calls runs once for square and once for cube, and each run makes its own calls variable, starting at 0, with its own wrapper remembering it (18.5). nonlocal calls lets the wrapper add 1 to that remembered count (18.6). So square’s count goes 1, 2, 3 across its three calls, while cube’s separate count is 1. func.__name__ gives the original names, and each wrapper returns the original’s result: 3 × 3, 4 × 4, 2 ** 3, and 5 × 5.

Reread 18.6 Changing a Remembered Variable with nonlocal and 18.7 Decorators: Functions That Wrap Functions if your output differed.

Question 22 · Short answer

Rewrite the Evens class from Question 3 as a generator function, evens(limit), that gives the same results. Then name two jobs the class had to do by hand that the generator function does for you.

Check your reasoning

One possible solution, with the same test lines as Question 3:

evens_gen.py
def evens(limit):
    number = 0
    while number <= limit:
        yield number
        number += 2


numbers = evens(5)
print(next(numbers))
print(list(numbers))
print(sum(evens(6)))
0
[2, 4]
12

Jobs the generator function does for you:

  1. Keeping the position. The class stored the next number in self.number and updated it on every __next__ call. In the generator, number is an ordinary local variable that stays alive while the generator is paused.
  2. Signalling the end. The class had to raise StopIteration itself. The generator raises it automatically when the while loop ends and the body finishes.

It also supplies __iter__ and __next__: a generator already is an iterator, so there is nothing to write for them. A correct answer names at least two of these.

Reread 18.2 Making Your Own Iterator: __iter__ and __next__ and 18.3 Generator Functions and yield if your result differed.

Question 23 · Multiple choice

For which job is a generator the worst fit, compared with a list?

  1. Adding up a number from each line of a 2 GB file
  2. Finding the first line of a log that contains "ERROR"
  3. Sorting a class’s test scores and printing the middle one, then the top three
  4. Handing out a new order number every time one is needed
Check your reasoning

C. Sorting needs every value at once, and the job then uses the scores again, by position (the middle one, then the top three). A list holds all the values, can be indexed, has a length, and can be used as often as needed; a generator can do none of those.

  • A is wrong: adding needs each number only once, one at a time, so a generator avoids holding a 2 GB file in memory.
  • B is wrong: a generator can stop as soon as the first match is found, without reading the rest of the log.
  • D is wrong: an endless supply is exactly what a generator with while True: gives, and a list cannot be endless.

Reread 18.4 Lazy Evaluation: Endless and Large Inputs if you chose differently.

Question 24 · Short answer

A friend says: “yield is just return for generators.” Explain what yield does differently from return, using what happens to the function’s local variables and what the next request does.

Check your reasoning

Model answer: return ends the function call. It hands back one value, the function’s local variables are thrown away, and calling the function again starts a brand-new run from the first line. yield hands back a value but does not end the function: it pauses it, with its local variables and its place kept. The next next() resumes on the line after the yield, with the variables as they were, so one generator can hand out many values over time, one per request. Also, calling a function with yield does not run its body at all; it returns a generator, and the body runs only as values are asked for.

A correct answer says that return ends the call and loses the local variables, while yield pauses and keeps them, so the next request continues where it left off.

Reread 18.3 Generator Functions and yield if your reasoning differed.

Wrap-up · 7 of 8

Go further

The official Python Tutorial explains the iteration protocol and generators in Classes: Iterators and Classes: Generators. The functools module documentation describes wraps and other tools for working with functions.

Wrap-up · 8 of 8

Reflection

Check each statement. If the answer is “not yet,” reread the linked subsection.

  • I can explain what a for loop does with iter(), next(), and StopIteration. Reread
  • I can tell an iterable from an iterator, and say why a generator can be used only once. Reread
  • I can write a class with __iter__ and __next__ that works in a for loop. Reread
  • I can write a generator function and explain what yield does differently from return. Reread
  • I can use an endless generator safely with islice, and process a large file with a generator pipeline. Reread
  • I can write a function that returns a closure, and explain what the closure remembers. Reread
  • I can use nonlocal to change a variable a closure remembers. Reread
  • I can write a decorator with a wrapper that takes *args and **kwargs, and apply it with @. Reread
  • I can use functools.wraps so a decorated function keeps its name and docstring. Reread