Skip to main content

itertools: chain, islice, groupby & product

Level 2: Level 2: Idiomsmedium12 minitertoolsiteratorslazinessiteration

Name the loops you keep rewriting, keep them lazy, and never call groupby on unsorted rows.

The loops you keep rewriting already have names

Most loops are one of a dozen shapes. Flatten a list of lists. Take the first ten of something. Walk runs of equal keys. Pair every item with every other item. itertools gives each of those shapes a name, and the name is worth more than the saved lines: a reader sees the intent immediately instead of reconstructing it from an accumulator and an index.

Everything in the module returns a lazy iterator, so nothing intermediate is built and the pipeline works just as well on a stream you could never fit in memory.

Table
Each of these is a loop you could write by hand in four lines. The value is not the line count: a named function says what the loop is FOR, and it hands back a lazy iterator, so nothing intermediate is ever materialised.
What you wantThe hand-written loopThe itertools name
Every item from several listsa nested for loop and an appendchain.from_iterable(chunks)
The first n of something unsliceablea counter and a breakislice(stream, n)
Runs of equal keysa previous-key variable and a buffergroupby(rows, key=...) over SORTED rows
Every ordered pairtwo nested for loopsproduct(items, repeat=2)
Every unordered paira nested loop starting at i + 1combinations(items, 2)
Each of these is a loop you could write by hand in four lines. The value is not the line count: a named function says what the loop is FOR, and it hands back a lazy iterator, so nothing intermediate is ever materialised.

chain: one flat pass over several sources

from itertools import chain

pages = [[1, 2], [3], [], [4, 5]]
list(chain.from_iterable(pages))     # [1, 2, 3, 4, 5]
list(chain([1, 2], [3]))             # same idea, sources passed separately

chain(a, b) takes the iterables as arguments; chain.from_iterable(pages) takes one iterable OF iterables, which is the form you want whenever the sources arrive as a list. Neither builds a combined list, so chaining a hundred files costs one file's worth of memory.

islice: slicing something you cannot subscript

A generator has no [0:3], because it has no index. islice gives it one:

from itertools import count, islice

list(islice(count(1), 5))        # [1, 2, 3, 4, 5] out of an infinite counter
list(islice(rows, 10, 20))       # start and stop, like a slice
Check yourself
stream is a generator. You call list(islice(stream, 3)) and then call list(islice(stream, 3)) again. What does the second call return?

groupby: runs, not groups

This is the one that surprises people, because the name is borrowed from SQL and the behaviour is not. groupby walks the input once and starts a new group every time the key CHANGES. It is closer to Unix uniq than to GROUP BY.

Check yourself
Rows arrive in the order core, web, core, web. You call groupby(rows, key=get_team) without sorting first. How many groups come out?
from itertools import groupby

rows = [{"team": "core"}, {"team": "web"}, {"team": "core"}]
ordered = sorted(rows, key=lambda row: row["team"])
{team: len(list(group)) for team, group in groupby(ordered, key=lambda row: row["team"])}
# {'core': 2, 'web': 1}

Sort by the same key you group by, every time. The Practice exercise is exactly this pairing, and skipping the sort is how it fails.

Check yourself
You write groups = list(groupby(ordered, key=get_team)) and then loop over groups to count each one. Every count comes out as zero. Why?

product and combinations: nested loops with names

from itertools import combinations, permutations, product

list(product("ab", repeat=2))       # [('a','a'), ('a','b'), ('b','a'), ('b','b')]
list(permutations("abc", 2))        # order matters, no reuse: 6 pairs
list(combinations("abc", 2))        # order does not matter, no reuse: 3 pairs
Check yourself
letters = ['a', 'b', 'c']. How many items are in list(product(letters, repeat=2)), and how many in list(combinations(letters, 2))?

Pitfalls

  • An iterator is single use. Anything from itertools is consumed as you read it, so calling list() twice on the same object gives you the contents and then an empty list. Materialise once if you need it twice.
  • chain(pages) is not chain.from_iterable(pages). The first treats the outer list as a single source and yields the inner lists themselves; the second flattens. It is a silent shape bug rather than an error.
  • groupby compares keys with == on adjacent items only. It never sorts, hashes, or reorders, so the correctness of your grouping rests entirely on the ordering you handed it.

Interview nuance: knowing groupby groups runs rather than values is a small fact that signals you have actually used the module rather than skimmed the docs. The wider point is worth saying out loud too: itertools pipelines are lazy, so islice(chain.from_iterable(files), 100) reads only as much of the first file as it needs and never materialises anything. That is the same streaming argument that makes generators worth reaching for at all.

Worked example (Python)
from itertools import chain, groupby, islice, product

print(list(chain.from_iterable([[1, 2], [3], [], [4, 5]])))   # [1, 2, 3, 4, 5]
print(list(islice(range(100), 5)))                            # [0, 1, 2, 3, 4]

rows = [{"team": "core"}, {"team": "web"}, {"team": "core"}]
ordered = sorted(rows, key=lambda row: row["team"])
print({t: len(list(g)) for t, g in groupby(ordered, key=lambda row: row["team"])})

unsorted_counts = {t: len(list(g)) for t, g in groupby(rows, key=lambda row: row["team"])}
print(unsorted_counts)   # {'core': 1, 'web': 1}: the second core run overwrote the first

print(len(list(product("abc", repeat=2))))   # 9

Apply

Your turn

The task this lesson builds to.

Write a function flatten(chunks) that returns one flat list holding every item from every list in chunks, in order.

For [[1, 2], [3]] return [1, 2, 3]. Empty inner lists contribute nothing. Use chain.from_iterable.

3 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

A daily report counts support tickets per team. The rows come back from the database in arrival order, someone reached straight for groupby, and the report showed eleven teams where there are four, because groupby starts a new group every time the key changes rather than gathering equal keys from across the list.

Implement count_by_team(tickets): each ticket is a dict with a "team" key. Return a dict mapping each team name to how many tickets it has. Sort by team first, then group, so every team appears exactly once.

For [{"team": "core"}, {"team": "web"}, {"team": "core"}] return {"core": 2, "web": 1}.

3 hints and 4 automated checks are waiting in the workspace.