All Advanced 2 55 min read 10 question quiz 19 exam marks

Iterators and Generators

How a for loop really works, and how to write functions that produce values one at a time with yield. Build your own iterators, write infinite generators, use generator expressions and itertools, and chain generators into a lazy pipeline that handles data of any size.

The iteration protocol

A for loop works on lists, strings, dictionaries, files, ranges and more. They have something in common, and it is a small protocol of two ideas:

  • an iterable is anything you can loop over. It can produce an iterator when asked, with iter().
  • an iterator is the object that does the work. Each call to next() gives the next item, and when there are none left it raises StopIteration.
Python
numbers = [10, 20, 30]
iterator = iter(numbers)

print(next(iterator))
print(next(iterator))
print(next(iterator))

try:
    next(iterator)
except StopIteration:
    print("no more items")

A for loop is a neat way of writing exactly that. Here it is written out by hand:

Python
def my_for(iterable, body):
    iterator = iter(iterable)
    while True:
        try:
            item = next(iterator)
        except StopIteration:
            break
        body(item)

my_for("abc", print)

Writing your own iterator

Any class with two methods works anywhere Python expects something to loop over: __iter__ (returning the iterator itself) and __next__ (returning the next value, or raising StopIteration).

Python
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

print(list(Countdown(3)))
for n in Countdown(2):
    print("liftoff in", n)

Iterators are used up

A list can be looped over again and again, because every call to iter() on it gives a fresh iterator. An iterator itself remembers its place and does not rewind:

Python
items = iter([1, 2, 3])
print(sum(items))
print(sum(items))                  # nothing is left

numbers = [1, 2, 3]
print(iter(numbers) is iter(numbers))      # each call makes a new iterator
print(iter(items) is items)                # an iterator is its own iterator
Iterable or iterator?

A list is an iterable: you can start over as often as you like. What iter() gives you is an iterator: a one way, one use cursor. Passing an iterator to two different loops means the second one finds nothing.

Generators: functions that pause

Writing a class with __iter__ and __next__ takes a lot of code for a simple idea. A generator function does the same thing with far less. Put yield where you would put return, and the function becomes a factory for iterators.

Python
def countdown(n):
    while n >= 0:
        yield n               # hand back a value, and pause here
        n -= 1

gen = countdown(3)
print(type(gen).__name__)
print(next(gen), next(gen))
print(list(gen))              # whatever is left

Calling a generator function does not run it. It returns a generator object. The body only runs when something asks for a value with next() (or a loop does). It runs until the next yield, hands the value over, and freezes, with all its local variables intact, until it is asked again. Watch the order of the prints:

Python
def noisy():
    print("start")
    yield 1
    print("middle")
    yield 2
    print("end")

g = noisy()
print("created")
print(next(g))
print(next(g))
try:
    next(g)
except StopIteration:
    print("finished")

When the function runs off the end, or reaches return, the generator raises StopIteration for you. That is the sign to a loop to stop.

Lazy means on demand

A list holds all its items in memory at once. A generator makes each value only when it is wanted. That has two big benefits. The first is that a generator can be infinite, because it never has to hold everything:

Python
from itertools import islice

def fibonacci():
    a, b = 0, 1
    while True:                # no end: it stops only when the caller stops asking
        yield a
        a, b = b, a + b

print(list(islice(fibonacci(), 12)))      # take just the first twelve

The second is that work can be avoided. This program wants the first square above 50. Compare how many squares each version calculates:

Python
work = {"squares": 0}

def squares_list(n):
    result = []
    for i in range(n):
        work["squares"] += 1
        result.append(i * i)
    return result

def squares_gen(n):
    for i in range(n):
        work["squares"] += 1
        yield i * i

def first_over(values, limit):
    for value in values:
        if value > limit:
            return value

first_over(squares_list(100000), 50)
print("list version worked out", work["squares"], "squares")

work["squares"] = 0
first_over(squares_gen(100000), 50)
print("generator version worked out", work["squares"], "squares")

The list version built all 100 000 squares before the search began. The generator stopped as soon as it found one. The same applies to memory: a generator holds one value at a time, however long the sequence.

ListGenerator
Valuesall made at oncemade one at a time, when asked
Memorygrows with the sizeone value
Can be infinitenoyes
Can be used againyesno: it is used up
Index or lengthitems[3], len(items)not available
A generator pauses at each yield and carries on from the same placeA generator function with six lines: print start, yield 1, print middle, yield 2, print end. Calling count does not run any of it: it only makes a generator object. The first call of next runs from the top to the first yield, so it prints start and gives back 1, and then the generator freezes with its variables kept. The second call of next carries on from that point, prints middle, and gives back 2. The third call carries on, prints end, runs off the end of the function, and raises StopIteration, which tells a loop to stop. The coloured bars match each stretch of code to the call that ran it.a generator functiondef count():    print('start')    yield 1    print('middle')    yield 2    print('end')calling count() runs nothing yet:it just makes a generator objectg = count()1each next() runs to the next yieldnext(g)prints startgives back:returns 1runs to the first yield, then freezesnext(g)prints middlegives back:returns 2carries on from where it frozenext(g)prints endgives back:StopIterationruns off the end: the loop stops2
  1. Calling a generator function runs none of its body. It returns a generator object.
  2. Each next() runs the body up to the next yield, hands a value back, and freezes the generator with its state kept.
Between calls the generator costs almost nothing: it holds only its place and its local variables, however long the sequence is.

Generator expressions, yield from and send

A list comprehension builds a whole list. Change the square brackets to round ones and you have a generator expression, which builds nothing until asked:

Python
import sys

squares_list = [n * n for n in range(5)]
squares_gen = (n * n for n in range(5))
print(squares_list)
print(type(squares_gen).__name__)

total = sum(n * n for n in range(1, 100001))       # a hundred thousand squares, never held at once
print(total)

big_list = list(range(100000))
small = (n for n in range(100000))
print("generator is smaller:", sys.getsizeof(small) < sys.getsizeof(big_list))

When a generator expression is the only argument to a function, the extra brackets can go: sum(n * n for n in ...).

Short-circuiting

any and all stop as soon as the answer is known. With a generator they also stop the work:

Python
def check(n):
    print("checking", n)
    return n > 2

print(any(check(n) for n in [1, 2, 3, 4, 5]))

Items 4 and 5 were never even checked.

yield from

A generator can hand over to another generator with yield from, which passes on every value it produces. It makes recursive generators short. This one flattens lists nested to any depth:

Python
def flatten(items):
    for item in items:
        if isinstance(item, list):
            yield from flatten(item)       # delegate to the inner generator
        else:
            yield item

print(list(flatten([1, [2, [3, 4]], 5])))

send: values going in as well

yield can also receive a value. The caller uses send(), which resumes the generator and makes the yield expression equal to whatever was sent. A generator used like this is called a coroutine. This one keeps a running average:

Python
def running_average():
    total, count, average = 0, 0, None
    while True:
        value = yield average         # hand back the average, wait for the next value
        total += value
        count += 1
        average = total / count

avg = running_average()
next(avg)                              # run to the first yield
print(avg.send(10))
print(avg.send(20))
print(avg.send(60))

Coroutines like this are the idea behind the tasks in the scheduling topic, and behind async and await.

itertools: building blocks

The itertools module is a toolbox of fast, lazy building blocks for working with iterators. Each one takes iterables and returns an iterator, so they can be chained.

ToolWhat it produces
count(10, 5)10, 15, 20, 25, ... without end
cycle("AB")A, B, A, B, ... without end
islice(it, 4)just the first 4 items of any iterator
chain(a, b)the items of a, then the items of b
accumulate(items)running totals
groupby(items)runs of equal neighbours, each with its group
product(a, b)every pairing of one item from each (a nested loop)
permutations(items, r)every ordering of r items
combinations(items, r)every choice of r items, ignoring order
pairwise(items)each item with the next one
takewhile(test, items)items until the test first fails
Python
from itertools import count, cycle, islice, chain, accumulate, groupby
from itertools import product, permutations, combinations, pairwise, takewhile, zip_longest

print(list(islice(count(10, 5), 4)))
print(list(islice(cycle("AB"), 5)))
print(list(chain([1, 2], "ab", (3,))))
print(list(accumulate([1, 2, 3, 4])))
print([(letter, len(list(group))) for letter, group in groupby("aaabbc")])
print(len(list(product("ab", repeat=3))), len(list(permutations("abcd", 2))), len(list(combinations(range(5), 2))))
print(list(pairwise([1, 4, 9, 16])))
print(list(takewhile(lambda n: n < 20, (n * n for n in count(1)))))
print(list(zip_longest("ab", [1, 2, 3], fillvalue="-")))

Notice what groupby did there: ("a", 3), ("b", 2), ("c", 1) is run length encoding, from the compression topic, in one line. It only groups neighbours, so sort first if you want every equal item together.

product is the lazy form of a nested loop, and counting is a quick way to size a search. A four digit PIN has len(list(product(range(10), repeat=4))) possibilities:

Python
from itertools import product, islice

pins = product(range(10), repeat=4)
print(len(list(product(range(10), repeat=4))), "possible four digit PINs")
print(list(islice(pins, 3)))
Iterators in, iterators out

Because every tool returns an iterator, you can combine them without ever making a big list. sum(x for x in chain(a, b) if x > 0) touches each item once, one at a time.

Pipelines: processing data of any size

Generators fit together like the stages of a factory line. Each takes an iterable and yields its output, and the next stage pulls from it. Items flow through one at a time. A pipeline can process a file of a hundred gigabytes using almost no memory, because only the item in each stage exists at any moment.

This pipeline reads log lines, turns each into a record, and keeps only the errors. The print in the first stage shows when each line is read:

Python
log = '''2025-03-01 INFO login ok user=amy
2025-03-01 ERROR disk full path=/var
2025-03-01 INFO upload ok user=ben size=40
2025-03-02 ERROR timeout host=db1
2025-03-02 WARN slow query ms=900
2025-03-02 ERROR timeout host=db2'''

def read_lines(text):
    for line in text.split("\n"):
        print("read:   ", line[:24])
        yield line

def parse(lines):
    for line in lines:
        date, level, message = line.split(" ", 2)
        yield {"date": date, "level": level, "message": message}

def only(level, records):
    for record in records:
        if record["level"] == level:
            yield record

pipeline = only("ERROR", parse(read_lines(log)))
print("pipeline built, nothing has run yet")
for record in pipeline:
    print("ERROR:  ", record["message"])

Nothing happens when the pipeline is built. Then, as the loop asks for each error, a line is read, parsed and tested, and the process repeats. The reads and the results are interleaved. A normal function-by-function version would read, and store, every line first.

Because each stage does one small job, each stage can be tested and reused on its own. The last stage can be any consumer: a loop, sum, max, list, or a Counter:

Python
from collections import Counter

log = '''2025-03-01 INFO login ok user=amy
2025-03-01 ERROR disk full path=/var
2025-03-01 INFO upload ok user=ben size=40
2025-03-02 ERROR timeout host=db1
2025-03-02 WARN slow query ms=900
2025-03-02 ERROR timeout host=db2'''

def parse(lines):
    for line in lines:
        date, level, message = line.split(" ", 2)
        yield {"date": date, "level": level, "message": message}

records = parse(log.split("\n"))
by_level = Counter(record["level"] for record in records)
print(by_level)
print("errors:", by_level["ERROR"], "| warnings:", by_level["WARN"])

Files are already lazy

When you write for line in open("big.log"), Python does not read the whole file. A file object is an iterator that reads one line at a time. A pipeline that starts with a file keeps that benefit all the way down.

Say what is evaluated when

A generator question usually asks for the output of a program that prints inside the generator. The marks are for the order: nothing prints when the generator is created, each next runs up to the next yield, and a value is returned there.

A pipeline of generators: log file, read lines, parse, keep the errors, use themFive stages in a row: a log file, then read lines, parse, only ERROR, and the for loop that uses the results. Each stage is a generator that takes the items of the stage before it and yields its own. Items flow through one at a time: a line is read, turned into a record, tested, and if it is an error its message reaches the loop. A dashed arrow pointing backwards shows that the loop pulls: it asks for the next error, and each stage asks the one before it. Because a line is read only when it is needed, the memory used is one line however big the file is.a lazy pipeline: one item at a time through every stagelog fileread_lines()yields a lineparse()yields a recordonly('ERROR')keeps the errorsfor loopuses each onea linea recordan errorits message1the loop asks for the next error, so each stage asks the stage before ita line is read only when it is needed, and nothing is held in a list2memory used: one line, however big the file is
  1. Each stage is a generator that takes the items of the stage before and yields its own.
  2. The loop pulls the data through: a line is read only when it is needed, so nothing is stored in a list.
Each small stage can be tested and reused on its own, and the whole pipeline works the same on ten lines or ten billion.

Key terms you must be able to define

Iterable
An object that can be looped over, because it can produce an iterator when iter() is called on it.
Iterator
An object that returns the next item from a sequence each time next() is called, and raises StopIteration when there are no more.
StopIteration
The exception that signals that an iterator has no more items. A for loop catches it and ends.
Generator
A function containing yield, which returns an iterator that produces its values one at a time and pauses between them.
yield
Hands a value back to the caller and pauses the generator, keeping its state, until the next value is requested.
Lazy evaluation
Working out values only when they are needed, instead of all at once.
Generator expression
A compact form like a list comprehension, written in round brackets, which produces values on demand.
yield from
Delegates to another iterator, passing each of its values on as if they came from this generator.
Coroutine
A generator that can also receive values, using send(), and so can be paused and resumed in both directions.
Pipeline
A chain of generators in which each stage takes values from the one before and passes results on one at a time.

Flashcards

Say the definition out loud before you flip the card. Retrieving it from memory is what builds recall, reading it again is not.

DefineIterable
AnswerAn object that can be looped over, because it can produce an iterator when iter() is called on it.
DefineIterator
AnswerAn object that returns the next item from a sequence each time next() is called, and raises StopIteration when there are no more.
DefineStopIteration
AnswerThe exception that signals that an iterator has no more items. A for loop catches it and ends.
DefineGenerator
AnswerA function containing yield, which returns an iterator that produces its values one at a time and pauses between them.
Defineyield
AnswerHands a value back to the caller and pauses the generator, keeping its state, until the next value is requested.
DefineLazy evaluation
AnswerWorking out values only when they are needed, instead of all at once.
DefineGenerator expression
AnswerA compact form like a list comprehension, written in round brackets, which produces values on demand.
Defineyield from
AnswerDelegates to another iterator, passing each of its values on as if they came from this generator.

Reach the top grade on this topic

Generator questions ask what is produced, and in what order, and why it is worth doing.

  • Say that calling a generator function does not run its body: it creates a generator object
  • For tracing, write the output in the order things happen: each next runs to the next yield
  • Give two benefits of laziness: low memory use, and no wasted work when the loop stops early
  • Say a generator can be used only once, and cannot be indexed or measured with len
  • For a pipeline, say each item passes through every stage before the next one is read

Mistakes that cost marks here

Avoid these
  • Expecting a generator function to print something or do work when it is called. Nothing happens until the first next.
  • Looping over the same generator twice. The second loop finds it empty.
  • Using return value in a generator when yield value was meant, which ends it at once.
  • Turning a generator into a list out of habit, which throws away the memory saving.
  • Using len() or an index on a generator. It has neither.
Fun fact

In Python 2, range(1000000) built a list of a million numbers before the loop started, and a separate xrange gave the lazy version. Python 3 made range lazy by default. The change shows how much of the language rests on producing values one at a time instead of all at once.

Knowledge check

0 of 10 answered
1What happens when a generator function is called?
2What does the yield statement do?
3What does a for loop do when the iterator raises StopIteration?
4What is the main advantage of a generator over a list for a very long sequence?
5A generator is used in a for loop which is finished. A second for loop is then run over the same generator. What happens?
6Which of these is a generator expression?
7What does yield from other() do?
8What does any(check(n) for n in items) do once check returns True?
9In a pipeline of generators, in what order are items processed?
10What does itertools.islice(fibonacci(), 10) give you?
Score 0 / 10

Exam-style questions

Five written questions in the style of the real paper. Write a full answer, then let the marker check it against the mark scheme.

19 marks
1Explain what is meant by a generator in Python, and give one advantage of using a generator instead of building a list.3 marks
Explain

Mark scheme0 / 3
  • LA generator is a function that uses yield to produce a sequence of values one at a time
  • LIt pauses after each value and carries on from the same place when the next is requested
  • LAdvantage: the values are made only when needed, so it uses far less memory than a list holding them all
Model answer

A generator is a function that uses yield to produce a sequence of values one at a time. After each value it pauses, keeping its local variables, and carries on from the same place when the next value is requested. An advantage is that the values are only made when they are needed, so a generator uses far less memory than a list that holds every value at once, and can even produce an infinite sequence.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
2The program shown is run. State the output, in the order it is displayed.4 marks
State
Python
def mystery(n):
    for i in range(n):
        print("making", i)
        yield i * 2

g = mystery(3)
print("start")
print(next(g))
print(next(g))

Mark scheme0 / 4
  • Lstart is output first, because calling the generator function does not run its body
  • Lmaking 0 is output when the first next is called
  • Lthen 0 is output by the print statement
  • Lmaking 1 and then 2 are output for the second next. The output is start, making 0, 0, making 1, 2
Model answer

The output is: start, making 0, 0, making 1, 2 (each on a separate line). Calling mystery(3) only creates the generator, so start is printed first. The first call of next runs the body until the first yield, which prints making 0 and returns 0, and that value is printed. The second call of next carries on from the yield, prints making 1 and returns 2, which is printed. The third item is never asked for, so making 2 is never printed.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
3Write a generator function called evens that takes a number, limit, and yields each of the even numbers from 0 up to but not including limit.3 marks
Write

Mark scheme0 / 3
  • LA function definition with a parameter and a loop
  • LSelects the even numbers (steps of 2 or a modulus test)
  • LUses yield to produce each value
Model answer
Python
def evens(limit):
    for n in range(0, limit, 2):
        yield n

print(list(evens(9)))

The function loops through the numbers from 0 in steps of 2 and uses yield to produce each one, so it is a generator. list(evens(9)) gives [0, 2, 4, 6, 8].

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
4Explain the difference between an iterable and an iterator in Python.3 marks
Explain

Mark scheme0 / 3
  • LAn iterable is an object that can be looped over, such as a list or string
  • LAn iterator is the object that produces the items one at a time, using next()
  • LAn iterator is used up: it keeps its position and raises StopIteration at the end, but an iterable can be looped over again and again
Model answer

An iterable is an object that can be looped over, such as a list or a string, and it can give a new iterator whenever one is needed. An iterator is the object that produces the items one at a time each time next() is called, and it remembers its position. When it has no more items it raises StopIteration, and it is then used up, whereas an iterable such as a list can be looped over again and again.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
5A program has to find the first line containing the word ERROR in a log file that is many gigabytes in size. Discuss whether the program should read the whole file into a list or process it using a generator.6 marks
Discuss

Mark scheme0 / 6
  • LReading the whole file into a list would need enough memory to hold every line
  • LThe file might be larger than the available memory, so the program could crash or become very slow
  • LA generator reads and processes one line at a time, so memory use stays small whatever the size of the file
  • LThe generator stops as soon as the first matching line is found, so the rest of the file is never read
  • LA list would allow the lines to be used again or looked up by index, which is not needed for this task
  • LA reasoned conclusion: use a generator (or iterate over the file line by line)
Model answer

A program that reads the whole file into a list would need enough memory to hold every line at once. If the file is several gigabytes this might be more than the computer has, so the program could crash or become very slow. A generator reads and handles one line at a time, so the memory used stays small whatever the size of the file. It also stops as soon as the first line containing ERROR is found, so the rest of the file is never read, saving time. A list would have the advantage that the lines could be used again or looked up by position, but this task does not need that. The program should therefore process the file with a generator, for example by looping over the file line by line.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
Machine mark 0 / 19