Iteration all 3 written
The protocol behind every for loop, generators that produce values lazily, and the itertools that do it correctly for you.
33 The iteration protocol, and what for really does
A for loop is not a language primitive. It is three method
calls in a loop, and knowing which three explains why some things can be iterated twice and others
cannot — which is the difference that causes the most confusion in everything built on top.
Two words that are not synonyms
An iterable is something you can get an iterator from: it has
__iter__. An iterator is the thing that actually produces values one at
a time: it has __next__. A list is iterable; the thing iter(list) hands back
is an iterator.
numbers = [10, 20, 30]
it = iter(numbers) # calls numbers.__iter__()
print("the list: ", type(numbers).__name__)
print("its iterator:", type(it).__name__)
print()
print("next:", next(it))
print("next:", next(it))
print("next:", next(it))
try:
next(it)
except StopIteration:
print("StopIteration -- the iterator is spent")
# The defining difference: an iterator is its own iterator, a list is not.
print()
print("iter(it) is it :", iter(it) is it)
print("iter(numbers) is numbers:", iter(numbers) is numbers)
test("iter() returns a different object for a list", iter(numbers) is numbers, False)
test("but an iterator returns itself", iter(it) is it, True)
test("a list has __iter__ but no __next__",
(hasattr(numbers, "__iter__"), hasattr(numbers, "__next__")), (True, False))
test("an iterator has both", (hasattr(it, "__iter__"), hasattr(it, "__next__")), (True, True))
What it printed, when the build ran it
the list: list its iterator: list_iterator next: 10 next: 20 next: 30 StopIteration -- the iterator is spent iter(it) is it : True iter(numbers) is numbers: False ok iter() returns a different object for a list ok but an iterator returns itself ok a list has __iter__ but no __next__ ok an iterator has both
A for loop, written out by hand
numbers = [1, 2, 3]
# What the for loop does, spelled out.
collected = []
iterator = iter(numbers) # 1. get an iterator
while True:
try:
value = next(iterator) # 2. ask for the next value
except StopIteration:
break # 3. stop when it says it is done
collected.append(value)
print("by hand:", collected)
# The same thing, written the way anyone would.
print("with for:", [value for value in numbers])
test("the manual version produces the same values", collected, [1, 2, 3])
test("which is exactly what for does", collected, list(numbers))
What it printed, when the build ran it
by hand: [1, 2, 3] with for: [1, 2, 3] ok the manual version produces the same values ok which is exactly what for does
This is why StopIteration is in the traceback catalogue as "not a
bug". Every for loop you have ever written raised and caught one on its final
iteration. You only see it when you call next() yourself and there is no loop to catch
it for you.
Why a list can be looped twice and an iterator cannot
numbers = [1, 2, 3]
# A list: each loop asks for a FRESH iterator, so both passes see everything.
first = [n for n in numbers]
second = [n for n in numbers]
print("list, pass 1:", first)
print("list, pass 2:", second)
# An iterator: there is only one, and it is consumed.
it = iter(numbers)
first_pass = [n for n in it]
second_pass = [n for n in it]
print()
print("iterator, pass 1:", first_pass)
print("iterator, pass 2:", second_pass, "<- empty, and no error")
test("a list can be iterated repeatedly", first, second)
test("an iterator gives everything on the first pass", first_pass, [1, 2, 3])
test("and nothing on the second", second_pass, [])
What it printed, when the build ran it
list, pass 1: [1, 2, 3] list, pass 2: [1, 2, 3] iterator, pass 1: [1, 2, 3] iterator, pass 2: [] <- empty, and no error ok a list can be iterated repeatedly ok an iterator gives everything on the first pass ok and nothing on the second
The second pass returns empty rather than raising, which is what makes this
dangerous. A function handed an exhausted iterator sees an empty sequence and reports zero
results — a plausible-looking wrong answer. If a value might be an iterator and you need it twice,
call list() on it once and pay the memory deliberately.
def summarise(values):
"""Looks harmless. Consumes 'values' twice."""
count = sum(1 for _ in values)
total = sum(values)
return count, total
# With a list: correct.
print("given a list: ", summarise([1, 2, 3]))
# With an iterator: the first sum consumed it, so the second sees nothing.
print("given an iterator:", summarise(iter([1, 2, 3])), "<- total is 0")
# The fix: materialise once, at the boundary.
def summarise_safely(values):
values = list(values)
return sum(1 for _ in values), sum(values)
print("with the fix: ", summarise_safely(iter([1, 2, 3])))
test("a list gives the right answer", summarise([1, 2, 3]), (3, 6))
test("an iterator silently gives a wrong one", summarise(iter([1, 2, 3])), (3, 0))
test("materialising fixes it", summarise_safely(iter([1, 2, 3])), (3, 6))
What it printed, when the build ran it
given a list: (3, 6) given an iterator: (3, 0) <- total is 0 with the fix: (3, 6) ok a list gives the right answer ok an iterator silently gives a wrong one ok materialising fixes it
Making your own class iterable
Implementing __iter__ is enough. The simplest correct approach is to return an iterator
over something that already is one — which is what lesson 29's Playlist did.
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
# A fresh iterator every time, so the object can be looped repeatedly.
return iter(range(self.start, 0, -1))
c = Countdown(3)
print("pass 1:", list(c))
print("pass 2:", list(c), "<- still works")
# And everything written against the protocol now works.
print()
print("sum: ", sum(c))
print("max: ", max(c))
print("sorted:", sorted(c))
print("in: ", 2 in c)
print("unpack:", [*c])
test("it can be iterated more than once", list(c), list(c))
test("sum works", sum(Countdown(3)), 6)
test("membership works", 2 in Countdown(3), True)
test("and unpacking works", [*Countdown(2)], [2, 1])
What it printed, when the build ran it
pass 1: [3, 2, 1] pass 2: [3, 2, 1] <- still works sum: 6 max: 3 sorted: [1, 2, 3] in: True unpack: [3, 2, 1] ok it can be iterated more than once ok sum works ok membership works ok and unpacking works
The trap: __iter__ returning self
Writing the iterator state directly on the object and returning self compiles, passes a
quick test, and is wrong: the object is now an iterator, so it can only be consumed once.
class BrokenCountdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self # the object IS the iterator -- one shot only
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
b = BrokenCountdown(3)
print("pass 1:", list(b))
print("pass 2:", list(b), "<- silently empty")
# Worse, because it breaks in ways that look unrelated.
b2 = BrokenCountdown(3)
print()
print("max() consumed it:", max(b2))
print("so sum() now sees:", sum(b2))
test("the first pass works", list(BrokenCountdown(3)), [3, 2, 1])
test("the second is empty", (lambda c: (list(c), list(c))[1])(BrokenCountdown(3)), [])
test("and any two consumers interfere", sum(b2), 0)
What it printed, when the build ran it
pass 1: [3, 2, 1] pass 2: [] <- silently empty max() consumed it: 3 so sum() now sees: 0 ok the first pass works ok the second is empty ok and any two consumers interfere
The rule. If your object represents a collection, make
__iter__ return a new iterator each time and do not give the object a
__next__. Only return self when the object genuinely represents a
single-use stream — a file, a network response, a cursor — where being consumable once is the
honest description.
# When a separate iterator class IS the right answer: state per iteration.
class Fibonacci:
"""A repeatable iterable over the first n Fibonacci numbers."""
def __init__(self, count):
self.count = count
def __iter__(self):
return FibonacciIterator(self.count)
class FibonacciIterator:
def __init__(self, count):
self.remaining = count
self.a, self.b = 0, 1
def __iter__(self):
return self # correct here: this IS the iterator
def __next__(self):
if self.remaining <= 0:
raise StopIteration
self.remaining -= 1
value, self.a, self.b = self.a, self.b, self.a + self.b
return value
fib = Fibonacci(8)
print("pass 1:", list(fib))
print("pass 2:", list(fib), "<- repeatable, because __iter__ built a new iterator")
test("the sequence is right", list(Fibonacci(8)), [0, 1, 1, 2, 3, 5, 8, 13])
test("and it is repeatable", list(fib), list(fib))
What it printed, when the build ran it
pass 1: [0, 1, 1, 2, 3, 5, 8, 13] pass 2: [0, 1, 1, 2, 3, 5, 8, 13] <- repeatable, because __iter__ built a new iterator ok the sequence is right ok and it is repeatable
The two-argument iter, and next with a default
# next() with a default returns it instead of raising.
it = iter([1])
print("first:", next(it, "nothing left"))
print("again:", next(it, "nothing left"))
# iter(callable, sentinel) calls the function until it returns the sentinel.
# This is how you read a stream in chunks without a while-True-and-break.
chunks = []
data = iter([b"abc", b"def", b"", b"ghi"])
def read_chunk():
return next(data)
for chunk in iter(read_chunk, b""): # stops at the empty bytes
chunks.append(chunk)
print()
print("chunks read before the sentinel:", chunks)
test("next with a default does not raise", next(iter([]), "empty"), "empty")
test("the sentinel form stops at the marker", chunks, [b"abc", b"def"])
What it printed, when the build ran it
first: 1 again: nothing left chunks read before the sentinel: [b'abc', b'def'] ok next with a default does not raise ok the sentinel form stops at the marker
And the error you get when something is not iterable at all, which names the missing method rather than being vague about it.
total = 0
for digit in 12345:
total += digit
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 2, in <module>
for digit in 12345:
^^^^^
TypeError: 'int' object is not iterable
What to take from this lesson
- An iterable has
__iter__; an iterator has__next__and returns itself from__iter__. - A
forloop isiter, thennextuntilStopIteration. - Iterators are consumed once, and a second pass gives an empty result rather than an error.
- Make
__iter__return a fresh iterator for a collection; returnselfonly for a genuine one-shot stream. next(it, default)avoids the exception;iter(callable, sentinel)loops until a marker value.
34 Generators, yield, and laziness that pays
A generator is a function that produces values one at a time instead of
returning once. Writing one is a matter of using yield instead of return — and
what you get back is an iterator, with all the properties lesson 33 established.
A function with yield is a different kind of function
def counted():
print(" -- body starts")
yield 1
print(" -- resumed after the first yield")
yield 2
print(" -- resumed after the second")
# Calling it runs NOTHING. It builds a generator object.
gen = counted()
print("after the call:", type(gen).__name__)
print()
print("first next():")
print(" got", next(gen))
print("second next():")
print(" got", next(gen))
print()
try:
next(gen)
except StopIteration:
print("StopIteration -- the function reached its end")
test("calling a generator function returns a generator", type(counted()).__name__, "generator")
test("and it is an iterator", hasattr(counted(), "__next__"), True)
test("collecting it gives the yielded values", list(counted()), [1, 2])
What it printed, when the build ran it
after the call: generator first next(): -- body starts got 1 second next(): -- resumed after the first yield got 2 -- resumed after the second StopIteration -- the function reached its end ok calling a generator function returns a generator ok and it is an iterator -- body starts -- resumed after the first yield -- resumed after the second ok collecting it gives the yielded values
That is the whole mechanism. yield hands a value out and
suspends the function, keeping its local variables and its position. The next
next() resumes exactly there. A generator is a function you can pause.
Memory: the practical argument
import sys
def squares_generator(n):
for i in range(n):
yield i * i
def squares_list(n):
return [i * i for i in range(n)]
gen = squares_generator(1_000_000)
lst = squares_list(1_000_000)
print("generator object:", f"{sys.getsizeof(gen):>9,} bytes")
print("list of a million:", f"{sys.getsizeof(lst):>9,} bytes")
print()
print("Both describe the same million values. One is storing them.")
# And the generator can be consumed exactly like the list.
print("first five from each:", list(squares_generator(5)), squares_list(5))
test("the generator is orders of magnitude smaller", sys.getsizeof(gen) * 1000 < sys.getsizeof(lst), True)
test("and yields the same values", list(squares_generator(5)), squares_list(5))
What it printed, when the build ran it
generator object: 208 bytes list of a million: 8,448,728 bytes Both describe the same million values. One is storing them. first five from each: [0, 1, 4, 9, 16] [0, 1, 4, 9, 16] ok the generator is orders of magnitude smaller ok and yields the same values
Infinite sequences, which a list cannot represent at all
from itertools import islice
def naturals():
n = 0
while True: # no termination -- and that is fine
yield n
n += 1
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
print("first 8 naturals: ", list(islice(naturals(), 8)))
print("first 8 fibonacci:", list(islice(fibonacci(), 8)))
# Taking from an infinite generator until a condition holds.
def first_above(threshold):
for value in fibonacci():
if value > threshold:
return value
print()
print("first fibonacci above 1000:", first_above(1000))
test("islice bounds an unbounded generator", list(islice(naturals(), 3)), [0, 1, 2])
test("fibonacci is correct", list(islice(fibonacci(), 8)), [0, 1, 1, 2, 3, 5, 8, 13])
test("and searching an infinite sequence terminates", first_above(1000), 1597)
What it printed, when the build ran it
first 8 naturals: [0, 1, 2, 3, 4, 5, 6, 7] first 8 fibonacci: [0, 1, 1, 2, 3, 5, 8, 13] first fibonacci above 1000: 1597 ok islice bounds an unbounded generator ok fibonacci is correct ok and searching an infinite sequence terminates
Never call list() on an unbounded generator. It will consume memory
until the process is killed. Bound it first with islice, a break, or a
condition — the laziness is what makes the infinite sequence possible, and materialising it throws that
away.
Laziness is visible: work happens only when asked
log = []
def tracked(values):
for v in values:
log.append(f"produced {v}")
yield v
# Building the generator does no work.
gen = tracked([1, 2, 3])
log_after_creation = list(log)
print("after creating it, log is:", log_after_creation)
# Each value is produced on demand.
print("asking for one:", next(gen))
print("log is now:", log)
# any() stops as soon as it has an answer, so the third is never produced.
log.clear()
found = any(v == 2 for v in tracked([1, 2, 3]))
print()
print("any(v == 2):", found)
print("log shows it stopped early:", log)
test("creating a generator produced nothing at all", log_after_creation, [])
test("any() short-circuits, so the third value was never produced", log, ["produced 1", "produced 2"])
What it printed, when the build ran it
after creating it, log is: [] asking for one: 1 log is now: ['produced 1'] any(v == 2): True log shows it stopped early: ['produced 1', 'produced 2'] ok creating a generator produced nothing at all ok any() short-circuits, so the third value was never produced
Pipelines: the pattern generators are for
Each stage takes an iterable and yields a transformed one. Nothing is materialised in between, so a multi-stage pipeline over a huge file uses the memory of a single row.
from pathlib import Path
Path("sales.csv").write_text(
"region,amount\n"
"north,100\n"
"\n" # a blank line, as real files have
"south,250\n"
"# a comment line\n"
"east,invalid\n"
"west,400\n",
encoding="utf-8",
)
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
def drop_blanks_and_comments(lines):
for line in lines:
if line and not line.startswith("#"):
yield line
def skip_header(lines):
iterator = iter(lines)
next(iterator, None) # discard the first
yield from iterator
def parse(lines):
for line in lines:
region, _, amount = line.partition(",")
try:
yield region, int(amount)
except ValueError:
continue # skip unparseable rows
# The pipeline is assembled but nothing has been read yet.
pipeline = parse(skip_header(drop_blanks_and_comments(read_lines("sales.csv"))))
print("assembled, nothing read yet:", type(pipeline).__name__)
rows = list(pipeline)
print("rows:", rows)
print("total:", sum(amount for _, amount in rows))
test("the pipeline is a generator until consumed", type(pipeline).__name__, "generator")
test("blank lines, comments, the header and bad rows were all dropped",
[r for r, _ in rows], ["north", "south", "west"])
test("and the amounts parsed", sum(a for _, a in rows), 750)
What it printed, when the build ran it
assembled, nothing read yet: generator
rows: [('north', 100), ('south', 250), ('west', 400)]
total: 750
ok the pipeline is a generator until consumed
ok blank lines, comments, the header and bad rows were all dropped
ok and the amounts parsed
yield from: delegating to another iterable
def inner():
yield "a"
yield "b"
return "inner finished" # a generator CAN return a value
def manual():
for value in inner(): # the long way
yield value
def delegated():
result = yield from inner() # shorter, and captures the return value
yield f"inner said {result!r}"
print("manual: ", list(manual()))
print("delegated:", list(delegated()))
# Flattening a nested structure, which is where yield from earns its keep.
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item) # recurse
else:
yield item
print()
print("flattened:", list(flatten([1, [2, [3, [4, 5]], 6], 7])))
test("yield from produces the same values as the loop",
list(manual()), ["a", "b"])
test("and additionally captures the return value",
list(delegated())[-1], "inner said 'inner finished'")
test("recursive flattening works", list(flatten([1, [2, [3]], 4])), [1, 2, 3, 4])
What it printed, when the build ran it
manual: ['a', 'b'] delegated: ['a', 'b', "inner said 'inner finished'"] flattened: [1, 2, 3, 4, 5, 6, 7] ok yield from produces the same values as the loop ok and additionally captures the return value ok recursive flattening works
A generator's return value does not appear among the yielded items. It is attached to the
StopIteration, which is where yield from reads it from.
def with_return():
yield 1
yield 2
return "the summary"
# Iterating never shows the return value.
print("list():", list(with_return()))
# It is on the StopIteration.
gen = with_return()
next(gen)
next(gen)
captured = None
try:
next(gen)
except StopIteration as exc:
captured = exc.value
print("StopIteration.value:", repr(captured))
test("the return value is not yielded", list(with_return()), [1, 2])
test("it travels on the StopIteration instead", captured, "the summary")
What it printed, when the build ran it
list(): [1, 2] StopIteration.value: 'the summary' ok the return value is not yielded ok it travels on the StopIteration instead
Generators are one-shot, which follows from lesson 33
def three():
yield from [1, 2, 3]
gen = three()
print("pass 1:", list(gen))
print("pass 2:", list(gen), "<- exhausted, as any iterator would be")
# Which means this common mistake gives a wrong answer, not an error.
gen = three()
print()
print("max:", max(gen))
print("sum of the same generator:", sum(gen), "<- nothing left")
# If you need it twice, call the function again -- it is cheap.
print()
print("calling it again:", max(three()), sum(three()))
test("a generator is exhausted after one pass",
(lambda g: (list(g), list(g))[1])(three()), [])
test("so two consumers interfere", sum(gen), 0)
test("calling the function again is the fix", (max(three()), sum(three())), (3, 6))
What it printed, when the build ran it
pass 1: [1, 2, 3] pass 2: [] <- exhausted, as any iterator would be max: 3 sum of the same generator: 0 <- nothing left calling it again: 3 6 ok a generator is exhausted after one pass ok so two consumers interfere ok calling the function again is the fix
Return the generator function, not the generator. If a caller may need the sequence
more than once, hand them something they can iterate repeatedly — a list, or a class with
__iter__ as in lesson 33. A function returning a bare generator is an interface that only
works once, and nothing in its signature says so.
Generators and with: cleanup is not guaranteed
A generator holding a resource open across a yield only closes it if the generator is
driven to completion or explicitly closed. Abandon it half-way and the cleanup waits for garbage
collection.
from pathlib import Path
Path("data.txt").write_text("a\nb\nc\n", encoding="utf-8")
state = {"closed": None}
def read_all(path):
f = open(path, encoding="utf-8")
try:
for line in f:
yield line.strip()
finally:
state["closed"] = True
# Consumed fully: the finally runs.
state["closed"] = False
lines = list(read_all("data.txt"))
closed_after_full = state["closed"]
print("fully consumed:", lines, "closed:", closed_after_full)
# Abandoned after one value: the finally has NOT run yet.
state["closed"] = False
gen = read_all("data.txt")
first = next(gen)
closed_while_open = state["closed"]
print("one value:", first, " closed:", closed_while_open, "<- still open")
# close() forces it.
gen.close()
closed_after_close = state["closed"]
print("after close():", closed_after_close)
test("full consumption runs the finally", closed_after_full, True)
test("abandoning it half-way does not", closed_while_open, False)
test("and close() runs it", closed_after_close, True)
What it printed, when the build ran it
fully consumed: ['a', 'b', 'c'] closed: True one value: a closed: False <- still open after close(): True ok full consumption runs the finally ok abandoning it half-way does not ok and close() runs it
send, throw and close
A generator is bidirectional: yield is an expression, and send supplies its
value. This is how coroutines were built before async/await existed, and it is
worth recognising rather than reaching for.
def accumulator():
total = 0
while True:
try:
received = yield total # yield out, and receive in
except ValueError:
print(" (told to reset)")
total = 0
continue
total += received
acc = accumulator()
primed = next(acc) # run up to the first yield
print("priming with next():", primed)
after_ten = acc.send(10)
print("send 10 ->", after_ten)
after_five = acc.send(5)
print("send 5 ->", after_five)
# throw() raises inside the generator, at the yield.
print("throwing ValueError:")
after_reset = acc.throw(ValueError)
print("after reset ->", after_reset)
after_seven = acc.send(7)
print("send 7 ->", after_seven)
acc.close()
test("priming yields the initial total", primed, 0)
test("each send adds to the running total", (after_ten, after_five), (10, 15))
test("throw() reached the except inside the generator and reset it", after_reset, 0)
test("and it kept accumulating afterwards", after_seven, 7)
What it printed, when the build ran it
priming with next(): 0 send 10 -> 10 send 5 -> 15 throwing ValueError: (told to reset) after reset -> 0 send 7 -> 7 ok priming yields the initial total ok each send adds to the running total ok throw() reached the except inside the generator and reset it ok and it kept accumulating afterwards
A generator must be primed before it can be sent a value, because there is no suspended
yield to receive it yet.
def accumulator():
total = 0
while True:
total += yield total
acc = accumulator()
acc.send(10)
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 7, in <module>
acc.send(10)
~~~~~~~~^^^^
TypeError: can't send non-None value to a just-started generator
Most code should not use send. It makes a generator into a state machine
whose behaviour depends on the order of calls, which is hard to read and harder to test. If you want
concurrency, lesson 43's async/await is built for it; if you want
accumulating state, a class with a method is clearer.
Generator expressions, once more
import sys
# A generator expression is a generator function without the def.
squares_expr = (n * n for n in range(5))
def squares_func():
for n in range(5):
yield n * n
print("expression:", list(squares_expr))
print("function: ", list(squares_func()))
print("same type: ", type((n for n in [])).__name__ == type(squares_func()).__name__)
# When the generator is the only argument, the brackets can be dropped.
print()
print("sum of squares:", sum(n * n for n in range(5)))
print("any over a file-sized sequence:", any(n > 100 for n in range(1000)))
test("both produce the same values", list(squares_func()), [0, 1, 4, 9, 16])
test("a generator expression is a generator",
type((n for n in [])).__name__, "generator")
test("and a bare one can be passed as the only argument", sum(n for n in range(4)), 6)
What it printed, when the build ran it
expression: [0, 1, 4, 9, 16] function: [0, 1, 4, 9, 16] same type: True sum of squares: 30 any over a file-sized sequence: True ok both produce the same values ok a generator expression is a generator ok and a bare one can be passed as the only argument
Which to write. A generator expression when the transformation fits on one line — the
same rule as lesson 12's comprehensions. A generator function when there is setup, multiple
yield points, a try/finally, or anything worth a name and a
docstring.
What to take from this lesson
- A function containing
yieldreturns a generator; calling it runs no code. yieldsuspends the function, keeping its state, andnext()resumes it.- Generators hold nothing, so they can represent unbounded sequences — never
list()one. - They are one-shot. Call the function again, or return something repeatable.
- Chain them into pipelines to process large data a row at a time.
yield fromdelegates and forwards the inner return value; that value arrives onStopIteration, not among the yields.
35 itertools, and the plumbing it writes for you
Almost every loop you write over a sequence has been written before, correctly,
in the standard library. itertools is that collection — all lazy, all working on any
iterable, and all avoiding a specific hand-rolled loop that is easy to get subtly wrong.
Joining and slicing
from itertools import chain, islice
first = [1, 2]
second = (3, 4)
third = {5}
# chain treats several iterables as one, without building a combined list.
print("chain:", list(chain(first, second, third)))
# chain.from_iterable flattens one level.
nested = [[1, 2], [3], [4, 5]]
print("from_iterable:", list(chain.from_iterable(nested)))
# islice slices any iterable, including one you cannot index.
gen = (n * n for n in range(100))
print()
print("islice(gen, 5): ", list(islice(gen, 5)))
print("islice(range, 2, 7):", list(islice(range(100), 2, 7)))
print("islice with a step: ", list(islice(range(20), 0, 20, 5)))
test("chain concatenates different iterable types", list(chain([1], (2,), {3})), [1, 2, 3])
test("from_iterable flattens one level", list(chain.from_iterable([[1, 2], [3]])), [1, 2, 3])
test("islice works on a generator, which cannot be sliced with []",
list(islice((n for n in range(10)), 3)), [0, 1, 2])
test("and takes start, stop and step", list(islice(range(20), 0, 20, 5)), [0, 5, 10, 15])
What it printed, when the build ran it
chain: [1, 2, 3, 4, 5] from_iterable: [1, 2, 3, 4, 5] islice(gen, 5): [0, 1, 4, 9, 16] islice(range, 2, 7): [2, 3, 4, 5, 6] islice with a step: [0, 5, 10, 15] ok chain concatenates different iterable types ok from_iterable flattens one level ok islice works on a generator, which cannot be sliced with [] ok and takes start, stop and step
Running totals and pairs
from itertools import accumulate, pairwise
import operator
readings = [3, 1, 4, 1, 5]
# accumulate: a running total, or any running application of a function.
print("running sum: ", list(accumulate(readings)))
print("running max: ", list(accumulate(readings, max)))
print("running product:", list(accumulate(readings, operator.mul)))
print("with an initial value:", list(accumulate(readings, initial=100)))
# pairwise: every adjacent pair, which replaces a zip(x, x[1:]) dance.
print()
print("pairwise:", list(pairwise(readings)))
# Which makes differences between consecutive readings a one-liner.
deltas = [b - a for a, b in pairwise(readings)]
print("deltas: ", deltas)
test("accumulate gives a running total", list(accumulate([1, 2, 3])), [1, 3, 6])
test("with a function it runs that instead", list(accumulate([3, 1, 4], max)), [3, 3, 4])
test("initial= prepends a starting value", list(accumulate([1, 2], initial=10)), [10, 11, 13])
test("pairwise yields adjacent pairs", list(pairwise([1, 2, 3])), [(1, 2), (2, 3)])
test("so differences are a comprehension", deltas, [-2, 3, -3, 4])
What it printed, when the build ran it
running sum: [3, 4, 8, 9, 14] running max: [3, 3, 4, 4, 5] running product: [3, 3, 12, 12, 60] with an initial value: [100, 103, 104, 108, 109, 114] pairwise: [(3, 1), (1, 4), (4, 1), (1, 5)] deltas: [-2, 3, -3, 4] ok accumulate gives a running total ok with a function it runs that instead ok initial= prepends a starting value ok pairwise yields adjacent pairs ok so differences are a comprehension
Chunking, with batched
from itertools import batched
ids = list(range(1, 10))
# batched splits an iterable into tuples of at most n. The last one is short.
for chunk in batched(ids, 4):
print(" chunk:", chunk)
print()
print("as a list:", [list(c) for c in batched(ids, 4)])
# The real use: sending records to something with a request-size limit.
def send(batch):
return f"sent {len(batch)} records"
print()
for chunk in batched(range(7), 3):
print(" ", send(chunk))
rejoined = [x for chunk in batched(range(7), 3) for x in chunk]
test("batched produces tuples", type(next(iter(batched([1, 2], 2)))).__name__, "tuple")
test("the final batch may be short", [len(c) for c in batched(range(7), 3)], [3, 3, 1])
test("and nothing is lost or duplicated", rejoined, list(range(7)))
What it printed, when the build ran it
chunk: (1, 2, 3, 4) chunk: (5, 6, 7, 8) chunk: (9,) as a list: [[1, 2, 3, 4], [5, 6, 7, 8], [9]] sent 3 records sent 3 records sent 1 records ok batched produces tuples ok the final batch may be short ok and nothing is lost or duplicated
groupby, and the requirement everyone misses
groupby groups consecutive items with the same key. It does not gather
matching items from across the sequence — so on unsorted input it produces a separate group each time the
key changes, which looks like grouping and is not.
from itertools import groupby
sales = [
("north", 100),
("south", 250),
("north", 150),
("south", 50),
]
print("UNSORTED input:")
for region, group in groupby(sales, key=lambda row: row[0]):
print(f" {region}: {[amount for _, amount in group]}")
print()
print("SORTED by the same key first:")
for region, group in groupby(sorted(sales, key=lambda row: row[0]), key=lambda row: row[0]):
print(f" {region}: {[amount for _, amount in group]}")
unsorted_groups = [k for k, _ in groupby(sales, key=lambda r: r[0])]
sorted_groups = [k for k, _ in groupby(sorted(sales), key=lambda r: r[0])]
test("unsorted input produced four groups for two regions", unsorted_groups,
["north", "south", "north", "south"])
test("sorting first produces two", sorted_groups, ["north", "south"])
What it printed, when the build ran it
UNSORTED input: north: [100] south: [250] north: [150] south: [50] SORTED by the same key first: north: [100, 150] south: [250, 50] ok unsorted input produced four groups for two regions ok sorting first produces two
Always sort by the same key you group by. The unsorted version above did not raise —
it returned four groups where a reader expects two, and the totals computed from it would be plausible
and wrong. If sorting is too expensive, or the key is unorderable, use a
defaultdict(list) instead: one pass, no sort, and no ordering requirement.
from itertools import groupby
from collections import defaultdict
sales = [("north", 100), ("south", 250), ("north", 150), ("south", 50)]
# With groupby: sort, then group.
by_groupby = {
region: sum(a for _, a in group)
for region, group in groupby(sorted(sales), key=lambda r: r[0])
}
# With defaultdict: one pass, no sort needed.
totals = defaultdict(int)
for region, amount in sales:
totals[region] += amount
print("groupby: ", by_groupby)
print("defaultdict:", dict(totals))
test("both agree", by_groupby, dict(totals))
test("and the totals are right", dict(totals), {"north": 250, "south": 300})
What it printed, when the build ran it
groupby: {'north': 250, 'south': 300}
defaultdict: {'north': 250, 'south': 300}
ok both agree
ok and the totals are right
The other groupby trap: each group is a lazy iterator that shares the underlying sequence,
so it is invalidated as soon as you move to the next group. Materialise each group before advancing.
from itertools import groupby
data = sorted([("a", 1), ("b", 2), ("a", 3)])
# Keeping the group objects to use later: they are all empty by then.
kept = [(key, group) for key, group in groupby(data, key=lambda r: r[0])]
print("groups kept for later:", [(k, list(g)) for k, g in kept])
# Materialising inside the loop: correct.
materialised = [(key, list(group)) for key, group in groupby(data, key=lambda r: r[0])]
print("materialised in the loop:", materialised)
test("group iterators are spent once you move on",
[(k, list(g)) for k, g in kept], [("a", []), ("b", [])])
test("materialising as you go works", materialised, [("a", [("a", 1), ("a", 3)]), ("b", [("b", 2)])])
What it printed, when the build ran it
groups kept for later: [('a', []), ('b', [])]
materialised in the loop: [('a', [('a', 1), ('a', 3)]), ('b', [('b', 2)])]
ok group iterators are spent once you move on
ok materialising as you go works
Combinations and products
from itertools import product, permutations, combinations, combinations_with_replacement
colours = ["red", "green"]
sizes = ["S", "M"]
# product: every combination across iterables -- a nested loop, flattened.
print("product:", list(product(colours, sizes)))
# Which replaces this:
manual = [(c, s) for c in colours for s in sizes]
print("by hand:", manual)
items = ["a", "b", "c"]
print()
print("permutations(2): ", list(permutations(items, 2)), "<- order matters")
print("combinations(2): ", list(combinations(items, 2)), "<- order does not")
print("with_replacement:", list(combinations_with_replacement(items, 2)))
# product with repeat= is how you enumerate fixed-length sequences.
print()
print("product('01', repeat=3):", ["".join(p) for p in product("01", repeat=3)])
test("product flattens a nested loop", list(product(colours, sizes)), manual)
test("permutations count order", len(list(permutations(items, 2))), 6)
test("combinations do not", len(list(combinations(items, 2))), 3)
test("repeat= enumerates fixed-length sequences", len(list(product("01", repeat=3))), 8)
What it printed, when the build ran it
product: [('red', 'S'), ('red', 'M'), ('green', 'S'), ('green', 'M')]
by hand: [('red', 'S'), ('red', 'M'), ('green', 'S'), ('green', 'M')]
permutations(2): [('a', 'b'), ('a', 'c'), ('b', 'a'), ('b', 'c'), ('c', 'a'), ('c', 'b')] <- order matters
combinations(2): [('a', 'b'), ('a', 'c'), ('b', 'c')] <- order does not
with_replacement: [('a', 'a'), ('a', 'b'), ('a', 'c'), ('b', 'b'), ('b', 'c'), ('c', 'c')]
product('01', repeat=3): ['000', '001', '010', '011', '100', '101', '110', '111']
ok product flattens a nested loop
ok permutations count order
ok combinations do not
ok repeat= enumerates fixed-length sequences
The infinite ones, and how to stop them
from itertools import count, cycle, repeat, islice, takewhile, dropwhile
# count: an endless counter, with an optional step.
print("count: ", list(islice(count(10, 5), 4)))
# cycle: repeats an iterable forever -- useful for round-robin assignment.
print("cycle: ", list(islice(cycle(["a", "b"]), 5)))
# repeat: the same value, n times or forever.
print("repeat: ", list(repeat("x", 3)))
# takewhile / dropwhile stop or start on a condition rather than a count.
readings = [1, 3, 8, 2, 9]
print()
print("takewhile < 5:", list(takewhile(lambda n: n < 5, readings)), "<- stops at the first failure")
print("dropwhile < 5:", list(dropwhile(lambda n: n < 5, readings)), "<- skips until the first failure")
# A real use for cycle: distributing work round-robin.
workers = cycle(["w1", "w2", "w3"])
assignment = [(task, next(workers)) for task in ["a", "b", "c", "d"]]
print()
print("round-robin:", assignment)
test("count with a step", list(islice(count(10, 5), 3)), [10, 15, 20])
test("cycle repeats", list(islice(cycle([1, 2]), 5)), [1, 2, 1, 2, 1])
test("takewhile stops at the first failure, ignoring later matches",
list(takewhile(lambda n: n < 5, readings)), [1, 3])
test("dropwhile keeps everything after it", list(dropwhile(lambda n: n < 5, readings)), [8, 2, 9])
test("cycle distributes round-robin", [w for _, w in assignment], ["w1", "w2", "w3", "w1"])
What it printed, when the build ran it
count: [10, 15, 20, 25]
cycle: ['a', 'b', 'a', 'b', 'a']
repeat: ['x', 'x', 'x']
takewhile < 5: [1, 3] <- stops at the first failure
dropwhile < 5: [8, 2, 9] <- skips until the first failure
round-robin: [('a', 'w1'), ('b', 'w2'), ('c', 'w3'), ('d', 'w1')]
ok count with a step
ok cycle repeats
ok takewhile stops at the first failure, ignoring later matches
ok dropwhile keeps everything after it
ok cycle distributes round-robin
takewhile is not filter. It stops permanently at the first
item that fails the test, so takewhile(n < 5, [1, 3, 8, 2]) gives
[1, 3] and drops the 2. filter would keep it. Reach for
takewhile when the sequence is ordered and you want a prefix; reach for
filter or a comprehension when you want every match.
tee, and the cost it hides
from itertools import tee
# tee gives you several independent iterators over one source.
source = iter([1, 2, 3])
a, b = tee(source, 2)
branch_a = list(a)
branch_b = list(b)
print("a:", branch_a)
print("b:", branch_b, "<- independent of a")
# But the source itself is consumed, and tee buffers internally.
leftover = list(source)
print("the original source is now:", leftover)
print()
print("tee has to remember every value one branch has seen and the other")
print("has not, so tee-ing then fully draining one branch buffers the lot.")
test("both branches see everything", (branch_a, branch_b), ([1, 2, 3], [1, 2, 3]))
test("and the original source is left with nothing", leftover, [])
What it printed, when the build ran it
a: [1, 2, 3] b: [1, 2, 3] <- independent of a the original source is now: [] tee has to remember every value one branch has seen and the other has not, so tee-ing then fully draining one branch buffers the lot. ok both branches see everything ok and the original source is left with nothing
Do not use the original iterator after tee. And if you were going to
drain one branch completely before touching the other, list() is simpler and uses the same
memory — tee only wins when the branches advance roughly together.
The promise from lesson 6: deque
Not itertools, but this is where it belongs. Lesson 6 noted that
list.pop(0) shifts every remaining element. collections.deque is the structure
with cheap removal from both ends.
from collections import deque
import timeit
# The same queue operation, on a list and on a deque.
def with_list(n):
q = list(range(n))
while q:
q.pop(0)
def with_deque(n):
q = deque(range(n))
while q:
q.popleft()
n = 20_000
list_time = timeit.timeit(lambda: with_list(n), number=3)
deque_time = timeit.timeit(lambda: with_deque(n), number=3)
print(f"draining {n:,} items from the front, three times")
print(f" list.pop(0): {list_time:.4f}s")
print(f" deque.popleft(): {deque_time:.4f}s")
print(f" the deque is about {list_time / deque_time:.0f}x faster here")
# deque also does both ends, and can be bounded.
d = deque([2, 3])
d.appendleft(1)
d.append(4)
print()
print("both ends:", list(d))
recent = deque(maxlen=3)
for value in range(6):
recent.append(value)
print("maxlen=3 keeps only the last three:", list(recent))
test("the deque is faster for front removal", deque_time < list_time, True)
test("appendleft and append both work", list(d), [1, 2, 3, 4])
test("maxlen discards from the other end", list(recent), [3, 4, 5])
What it printed, when the build ran it
draining 20,000 items from the front, three times list.pop(0): 0.0804s deque.popleft(): 0.0035s the deque is about 23x faster here both ends: [1, 2, 3, 4] maxlen=3 keeps only the last three: [3, 4, 5] ok the deque is faster for front removal ok appendleft and append both work ok maxlen discards from the other end
What to take from this lesson
chainto treat several iterables as one;isliceto slice something you cannot index.accumulatefor running totals,pairwisefor adjacent pairs,batchedfor size-limited chunks.groupbygroups only consecutive equal keys — sort first, or use adefaultdict. And materialise each group before moving on.productreplaces nested loops;combinationsandpermutationsdiffer on whether order matters.takewhilestops at the first failure; it is notfilter.deque, notlist, when you remove from the front.