Python’s itertools - The Missing Manual
A deep dive into one of Python’s most underused standard library modules
Wait, Why Should I Care About Another Module?
You’ve written Python for a while. You know how to use for loops, list comprehensions, zip, and enumerate. Things work. So when someone says “hey, you should learn itertools”, it’s easy to nod politely and move on.
Here’s what changes your mind. Imagine you’re asked to generate all possible batting orders for 4 players chosen from a squad of 11. Or you need to group a massive log file by date without loading it all into memory. Or you want to pair up every possible combination of feature columns for a machine learning experiment.
Without itertools, you end up writing nested loops, managing index variables, building intermediate lists, and generally fighting Python to do what should be simple. With itertools, each of those problems is one function call.
This guide is a complete reference for itertools - written not as a dry API listing, but as a walkthrough that builds your intuition for when to reach for it and why it works the way it does.
What Exactly Is itertools?
itertools is a module in Python’s standard library - no installation needed, just import itertools. It provides a collection of fast, memory-efficient tools for working with iterators.
The name is a compound of two words: iter (as in iterators) and tools (as in building blocks). That framing is intentional. The module’s author, Raymond Hettinger, designed it specifically to give you composable primitives - small, focused functions that you can chain together into powerful pipelines.
Every function in itertools returns an iterator, not a list. This is the fundamental design choice that makes the whole module useful:
import itertools
# This does NOT build a list of 1 trillion numbers in memory
counter = itertools.count(start=1)
# It creates a lazy iterator - values computed one at a time, on demand
print(next(counter)) # 1
print(next(counter)) # 2
print(next(counter)) # 3
# The other 999,999,999,997 values haven't been computed yet
If it returned a list, itertools.count() would be impossible to use. Because it returns an iterator, it’s perfectly fine - you pull values out one at a time.
The Problem It Solves
Let’s be concrete. Here are three real situations where itertools replaces messy code.
Problem 1: Combinations and Permutations
You want to test every possible pair of columns in a dataset with 10 columns. How many pairs is that? 10 * 9 / 2 = 45. Writing nested loops is clunky and error-prone.
# Without itertools - manual nested loop
columns = ['age', 'salary', 'score', 'tenure']
pairs = []
for i in range(len(columns)):
for j in range(i + 1, len(columns)):
pairs.append((columns[i], columns[j]))
print(pairs)
# With itertools - one line
import itertools
pairs = list(itertools.combinations(columns, 2))
print(pairs)
# [('age', 'salary'), ('age', 'score'), ('age', 'tenure'),
# ('salary', 'score'), ('salary', 'tenure'), ('score', 'tenure')]
Problem 2: Grouping Data
You have a list of transactions sorted by category and want to total each category. Without itertools, you write a dict-accumulation loop. With it, you use groupby and the intent is obvious.
transactions = [
("food", 150), ("food", 200), ("travel", 800),
("travel", 1200), ("food", 75), ("utilities", 900),
]
# Without itertools
totals = {}
for category, amount in transactions:
totals[category] = totals.get(category, 0) + amount
# With itertools.groupby (after sorting)
from itertools import groupby
from operator import itemgetter
sorted_txns = sorted(transactions, key=itemgetter(0))
for category, group in groupby(sorted_txns, key=itemgetter(0)):
total = sum(amt for _, amt in group)
print(f"{category}: Rs.{total}")
Problem 3: Infinite Sequences
You need to cycle through a list of colours to assign alternating row colours in a report. You don’t know the number of rows ahead of time.
# Without itertools - manual index tracking
colours = ['white', 'lightgrey']
rows = get_report_rows() # could be any length
for i, row in enumerate(rows):
colour = colours[i % len(colours)]
render_row(row, background=colour)
# With itertools - the intent is crystal clear
from itertools import cycle
colours = cycle(['white', 'lightgrey'])
for row, colour in zip(get_report_rows(), colours):
render_row(row, background=colour)
In every case, itertools doesn’t just shorten the code - it makes the intent clearer. The function name tells you exactly what’s happening.
How to Think About itertools
Before looking at individual functions, there’s a mental model that unifies the whole module. The functions fall into three categories:
┌─────────────────────────────────────────────────────────────────────┐
│ ITERTOOLS FUNCTION FAMILIES │
├──────────────────────────────┬──────────────────────────────────────┤
│ INFINITE ITERATORS │ Generate values without end │
│ count, cycle, repeat │ Always pair with islice or zip │
├──────────────────────────────┼──────────────────────────────────────┤
│ FINITE ITERATORS │ Operate on finite input sequences │
│ chain, islice, zip_longest, │ Transform, filter, or combine │
│ starmap, takewhile, │ existing iterables │
│ dropwhile, groupby, │ │
│ compress, filterfalse, │ │
│ accumulate, pairwise, │ │
│ batched, product │ │
├──────────────────────────────┼──────────────────────────────────────┤
│ COMBINATORIC ITERATORS │ Generate structured combinations │
│ combinations, │ Essential for search, testing, │
│ combinations_with_repl, │ and mathematical problems │
│ permutations, product │ │
└──────────────────────────────┴──────────────────────────────────────┘
One more thing to internalize: itertools functions are lazy. None of them do any work until you ask for the next value. This means you can chain ten itertools calls together and the total memory used is still proportional to one element at a time, not the full dataset.
Things to Keep in Mind Before You Start
These are the rules that trip people up most often. Read them once before diving into examples.
1. All itertools functions return iterators, not lists.
To see the values, wrap in list() or tuple(), or iterate in a loop. Don’t be surprised when print(itertools.chain([1,2],[3,4])) shows <itertools.chain object at 0x...>.
2. Iterators are single-pass. Once exhausted, you can’t rewind. If you need to iterate twice, either call the function again or convert to a list.
from itertools import chain
it = chain([1, 2], [3, 4])
print(list(it)) # [1, 2, 3, 4]
print(list(it)) # [] ← already exhausted!
3. groupby only groups consecutive elements.
If your data isn’t sorted, groupby will produce multiple groups for the same key. Always sort first if you want all items of the same key together.
from itertools import groupby
data = [1, 1, 2, 1, 1] # 1s are NOT consecutive everywhere
for key, group in groupby(data):
print(key, list(group))
# 1 [1, 1]
# 2 [2]
# 1 [1, 1] ← a SECOND group of 1s, not merged with the first!
4. Always call islice to guard infinite iterators.
itertools.count(), itertools.cycle(), and itertools.repeat() run forever. Passing them to list() will hang your program. Use islice to take only what you need.
5. Combinatoric functions can produce astronomically large outputs.
permutations(range(20)) produces 2,432,902,008,176,640,000 tuples. Use islice or consume lazily.
6. chain takes multiple iterables as arguments; chain.from_iterable takes one iterable of iterables.
from itertools import chain
# chain(*args) - pass iterables directly
list(chain([1,2], [3,4], [5,6])) # [1, 2, 3, 4, 5, 6]
# chain.from_iterable - pass one iterable that contains iterables
nested = [[1,2], [3,4], [5,6]]
list(chain.from_iterable(nested)) # [1, 2, 3, 4, 5, 6]
Part 1: Infinite Iterators
These three functions generate values indefinitely. They’re the simplest functions in the module but among the most useful.
count(start=0, step=1) - An Infinite Counter
count produces an infinite sequence of evenly-spaced numbers. Think of it as a programmable counter that never stops.
from itertools import count, islice
# Basic counting
for n in islice(count(), 5):
print(n, end=" ") # 0 1 2 3 4
# Start and step
for n in islice(count(10, 5), 5):
print(n, end=" ") # 10 15 20 25 30
# Counting backwards
for n in islice(count(100, -10), 6):
print(n, end=" ") # 100 90 80 70 60 50
# Floating point steps
for n in islice(count(0.0, 0.5), 6):
print(round(n, 1), end=" ") # 0.0 0.5 1.0 1.5 2.0 2.5
Real-world use case: Assigning auto-incrementing IDs to records coming in from a stream, without knowing how many records there will be.
from itertools import count
def assign_ids(records):
"""Attach a unique ID to each record in a stream."""
id_counter = count(start=1001)
for record in records:
record["id"] = next(id_counter)
yield record
players = [
{"name": "Rohit Sharma"},
{"name": "Virat Kohli"},
{"name": "Jasprit Bumrah"},
]
for player in assign_ids(players):
print(player)
# {'name': 'Rohit Sharma', 'id': 1001}
# {'name': 'Virat Kohli', 'id': 1002}
# {'name': 'Jasprit Bumrah', 'id': 1003}
cycle(iterable) - Loop an Iterable Forever
cycle takes any iterable and loops through it indefinitely: A, B, C, A, B, C, A, B, …
from itertools import cycle, islice
colours = cycle(['red', 'green', 'blue'])
print(list(islice(colours, 8)))
# ['red', 'green', 'blue', 'red', 'green', 'blue', 'red', 'green']
# Assign round-robin teams
from itertools import cycle
teams = cycle(['Team A', 'Team B', 'Team C'])
players = ['Ravi', 'Priya', 'Arjun', 'Meera', 'Suresh', 'Kavya', 'Dev']
assignments = list(zip(players, teams))
for player, team in assignments:
print(f"{player} → {team}")
# Ravi → Team A
# Priya → Team B
# Arjun → Team C
# Meera → Team A
# Suresh → Team B
# Kavya → Team C
# Dev → Team A
Real-world use case: Load balancing requests across multiple servers.
from itertools import cycle
servers = cycle(['server-1', 'server-2', 'server-3'])
def route_request(request):
server = next(servers)
print(f"Routing {request!r} → {server}")
route_request("/api/users") # → server-1
route_request("/api/orders") # → server-2
route_request("/api/products") # → server-3
route_request("/api/users") # → server-1 (cycles back)
repeat(object, times=None) - Repeat a Value
repeat produces the same value over and over, either a fixed number of times or infinitely.
from itertools import repeat
# Fixed repetition
print(list(repeat("hello", 4))) # ['hello', 'hello', 'hello', 'hello']
# Often used with map to supply a constant second argument
doubles = list(map(pow, range(1, 6), repeat(2)))
print(doubles) # [1, 4, 9, 16, 25] - same as [x**2 for x in range(1,6)]
# Filling a default grid
grid = [list(repeat(0, 4)) for _ in repeat(None, 3)]
print(grid)
# [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
Part 2: Finite Iterators
These are the workhorses of the module. They transform, filter, slice, and combine iterables.
chain(*iterables) - Flatten Multiple Iterables Into One
chain takes multiple iterables and yields from each in sequence, as if they were one long iterable. It’s the lazy version of concatenation.
from itertools import chain
# Basic usage
combined = list(chain([1, 2, 3], [4, 5], [6, 7, 8, 9]))
print(combined) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
# Works with any iterables - lists, tuples, strings, generators
result = list(chain("IPL", [2024], ("T20", "Cricket")))
print(result) # ['I', 'P', 'L', 2024, 'T20', 'Cricket']
chain.from_iterable - when you have a list of lists (dynamic nesting):
from itertools import chain
# Flatten one level deep
nested = [[1, 2, 3], [4, 5], [6, 7, 8]]
flat = list(chain.from_iterable(nested))
print(flat) # [1, 2, 3, 4, 5, 6, 7, 8]
# Real use: merging query results from multiple DB shards
def get_orders_from_shards(shards):
return chain.from_iterable(shard.get_orders() for shard in shards)
# Real use: word tokenization across multiple sentences
sentences = ["The quick brown fox", "jumps over", "the lazy dog"]
words = list(chain.from_iterable(s.split() for s in sentences))
print(words)
# ['The', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']
islice(iterable, stop) / islice(iterable, start, stop, step) - Lazy Slicing
islice is like Python’s slice notation [start:stop:step] but for any iterator - including infinite ones. It does not support negative indices.
from itertools import islice, count
# Take first N elements
first_five = list(islice(count(), 5))
print(first_five) # [0, 1, 2, 3, 4]
# Skip first N, take next M
result = list(islice(count(0), 5, 10))
print(result) # [5, 6, 7, 8, 9]
# With a step
result = list(islice(range(100), 0, 30, 5))
print(result) # [0, 5, 10, 15, 20, 25]
# Paginate a large dataset - page 3, 10 items per page
def get_page(iterable, page_number, page_size=10):
start = (page_number - 1) * page_size
return list(islice(iterable, start, start + page_size))
data = range(1, 101)
print(get_page(data, page=3, page_size=10)) # [21, 22, ..., 30]
zip_longest(*iterables, fillvalue=None) - Zip Without Truncating
Python’s built-in zip stops at the shortest iterable. zip_longest continues until all iterables are exhausted, filling missing values with a default.
from itertools import zip_longest
names = ['Rohit', 'Virat', 'Dhoni']
scores = [89, 112]
overs = [18.4, 20.0, 15.2, 19.1]
result = list(zip_longest(names, scores, overs, fillvalue='N/A'))
for row in result:
print(row)
# ('Rohit', 89, 18.4)
# ('Virat', 112, 20.0)
# ('Dhoni', 'N/A', 15.2)
# ('N/A', 'N/A', 19.1)
# Merging two ordered streams - useful in merge sort logic
stream_a = [1, 3, 5, 7]
stream_b = [2, 4, 6]
pairs = list(zip_longest(stream_a, stream_b, fillvalue=0))
print(pairs) # [(1, 2), (3, 4), (5, 6), (7, 0)]
accumulate(iterable, func=operator.add, *, initial=None) - Running Totals
accumulate applies a function cumulatively - each output value is the result of applying the function to all elements seen so far. The default function is addition, giving running totals.
from itertools import accumulate
import operator
# Running sum (default)
sales = [120, 340, 210, 450, 180, 300]
running_total = list(accumulate(sales))
print(running_total) # [120, 460, 670, 1120, 1300, 1600]
# Running maximum - track best score seen so far
match_scores = [67, 82, 45, 95, 71, 88, 100, 55]
running_max = list(accumulate(match_scores, func=max))
print(running_max)
# [67, 82, 82, 95, 95, 95, 100, 100]
# Running product
values = [1, 2, 3, 4, 5]
running_product = list(accumulate(values, func=operator.mul))
print(running_product) # [1, 2, 6, 24, 120]
# With initial value (Python 3.8+)
running_with_start = list(accumulate([10, 20, 30], initial=100))
print(running_with_start) # [100, 110, 130, 160]
# Real-world: cumulative rainfall over months
rainfall_mm = [45, 62, 28, 90, 115, 70, 33, 80, 55, 40, 20, 10]
months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
cumulative = list(accumulate(rainfall_mm))
for month, rain, total in zip(months, rainfall_mm, cumulative):
print(f"{month}: {rain}mm (cumulative: {total}mm)")
takewhile(predicate, iterable) - Take While Condition Holds
takewhile yields elements as long as the predicate returns True. It stops permanently at the first False - it does not resume.
from itertools import takewhile
numbers = [2, 4, 6, 8, 3, 10, 12]
evens_until_odd = list(takewhile(lambda x: x % 2 == 0, numbers))
print(evens_until_odd) # [2, 4, 6, 8] ← stops at 3, does NOT include 10 or 12
# Reading a log file until a certain timestamp is reached
log_lines = [
"2024-01-01 INFO Server started",
"2024-01-01 INFO Request received",
"2024-01-02 WARNING Disk space low",
"2024-01-03 ERROR Database timeout",
"2024-01-03 INFO Retry succeeded",
]
jan_first_logs = list(takewhile(lambda line: line.startswith("2024-01-01"), log_lines))
print(jan_first_logs)
# ['2024-01-01 INFO Server started', '2024-01-01 INFO Request received']
# Streaming sensor data: read until temperature exceeds threshold
def safe_readings(sensor_stream, max_temp=80):
return takewhile(lambda reading: reading['temp'] <= max_temp, sensor_stream)
dropwhile(predicate, iterable) - Skip While Condition Holds
dropwhile is the mirror of takewhile. It skips elements while the predicate is True, then yields everything from the first False onward.
from itertools import dropwhile
numbers = [2, 4, 6, 8, 3, 10, 12]
after_first_odd = list(dropwhile(lambda x: x % 2 == 0, numbers))
print(after_first_odd) # [3, 10, 12] ← skipped 2,4,6,8; yields from 3 onward
# Skip file header lines that start with '#'
config_lines = [
"# Configuration file",
"# Generated automatically",
"host=localhost",
"port=5432",
"# This comment appears mid-file",
"db=mydb",
]
non_comment_lines = list(dropwhile(lambda l: l.startswith('#'), config_lines))
print(non_comment_lines)
# ['host=localhost', 'port=5432', '# This comment appears mid-file', 'db=mydb']
# Note: the mid-file comment IS included because dropwhile only skips the leading ones
filterfalse(predicate, iterable) - Keep What a Filter Would Reject
While filter(func, iterable) keeps elements where func returns True, filterfalse keeps elements where it returns False.
from itertools import filterfalse
numbers = range(10)
# filter keeps evens, filterfalse keeps odds
evens = list(filter(lambda x: x % 2 == 0, numbers))
odds = list(filterfalse(lambda x: x % 2 == 0, numbers))
print(evens) # [0, 2, 4, 6, 8]
print(odds) # [1, 3, 5, 7, 9]
# Partition a list into two groups simultaneously
def partition(predicate, iterable):
"""Split an iterable into (items where pred is True, items where False)."""
from itertools import filterfalse, tee
t1, t2 = tee(iterable)
return filter(predicate, t1), filterfalse(predicate, t2)
students = [
{"name": "Ravi", "score": 78},
{"name": "Priya", "score": 55},
{"name": "Arjun", "score": 92},
{"name": "Meera", "score": 43},
]
passing, failing = partition(lambda s: s["score"] >= 60, students)
print("Passed:", [s["name"] for s in passing]) # ['Ravi', 'Arjun']
print("Failed:", [s["name"] for s in failing]) # ['Priya', 'Meera']
compress(data, selectors) - Boolean Masking
compress takes two iterables: data and boolean selectors. It yields only the data items where the corresponding selector is True (or truthy). Think of it as numpy boolean indexing, but for any iterator.
from itertools import compress
data = ['A', 'B', 'C', 'D', 'E', 'F']
selectors = [True, False, True, False, True, True]
selected = list(compress(data, selectors))
print(selected) # ['A', 'C', 'E', 'F']
# Real use: select columns from a CSV row based on a flag list
columns = ['name', 'age', 'salary', 'city', 'phone', 'email']
include = [True, True, False, True, False, True ]
selected_cols = list(compress(columns, include))
print(selected_cols) # ['name', 'age', 'city', 'email']
# Combine with a search result to mask items
products = ['Laptop', 'Phone', 'Tablet', 'Watch', 'Earbuds']
in_stock = [True, False, True, True, False ]
available = list(compress(products, in_stock))
print(available) # ['Laptop', 'Tablet', 'Watch']
groupby(iterable, key=None) - Group Consecutive Elements
groupby scans through an iterable and groups consecutive elements that share the same key. It yields (key, group_iterator) pairs.
Critical reminder: Only consecutive equal keys are grouped. Always sort first if you want all matching items together.
from itertools import groupby
from operator import itemgetter
# Simple grouping
data = [1, 1, 2, 2, 2, 3, 1, 1]
for key, group in groupby(data):
print(key, list(group))
# 1 [1, 1]
# 2 [2, 2, 2]
# 3 [3]
# 1 [1, 1] ← separate group because not consecutive with the first
# Real use: group sorted transactions by category
transactions = [
("food", 120),
("food", 340),
("travel", 800),
("travel", 1200),
("utilities", 450),
("food", 95), # ← would be a separate group if not pre-sorted
]
sorted_txns = sorted(transactions, key=itemgetter(0))
print("\nCategory Summary:")
for category, group in groupby(sorted_txns, key=itemgetter(0)):
amounts = [amt for _, amt in group]
print(f" {category}: {len(amounts)} transactions, total Rs.{sum(amounts)}")
# food: 3 transactions, total Rs.555
# travel: 2 transactions, total Rs.2000
# utilities: 1 transactions, total Rs.450
Advanced groupby - group by computed property:
from itertools import groupby
ipl_players = [
{"name": "Rohit Sharma", "team": "MI"},
{"name": "Hardik Pandya", "team": "MI"},
{"name": "Virat Kohli", "team": "RCB"},
{"name": "Glenn Maxwell", "team": "RCB"},
{"name": "MS Dhoni", "team": "CSK"},
{"name": "Ruturaj Gaikwad", "team": "CSK"},
]
sorted_players = sorted(ipl_players, key=lambda p: p["team"])
for team, members in groupby(sorted_players, key=lambda p: p["team"]):
names = [p["name"] for p in members]
print(f"{team}: {', '.join(names)}")
# CSK: MS Dhoni, Ruturaj Gaikwad
# MI: Rohit Sharma, Hardik Pandya
# RCB: Virat Kohli, Glenn Maxwell
starmap(func, iterable) - Map with Argument Unpacking
starmap is like map, but it unpacks each element of the iterable as arguments to the function. Useful when your iterable contains tuples of arguments.
from itertools import starmap
# map requires separate iterables; starmap takes one iterable of tuples
coordinates = [(2, 3), (4, 5), (1, 8), (3, 3)]
distances = list(starmap(pow, [(2, 3), (3, 2), (10, 0.5)]))
print(distances) # [8, 9, 3.1622...] (2^3, 3^2, 10^0.5)
# Real use: apply functions to argument tuples
import operator
operations = [
(operator.add, 10, 5),
(operator.mul, 4, 7),
(operator.sub, 20, 3),
]
results = [func(a, b) for func, a, b in operations]
print(results) # [15, 28, 17]
# starmap with a named function
def compute_tax(price, rate_pct):
return round(price * rate_pct / 100, 2)
products = [
("Laptop", 75000, 18),
("Book", 500, 5),
("Phone", 25000, 12),
]
taxes = list(starmap(compute_tax, [(p[1], p[2]) for p in products]))
for (name, price, rate), tax in zip(products, taxes):
print(f"{name}: Rs.{price} + {rate}% tax = Rs.{price + tax}")
pairwise(iterable) - Sliding Window of Size 2 (Python 3.10+)
pairwise yields consecutive overlapping pairs from an iterable. This is the most common sliding window pattern.
from itertools import pairwise
data = [1, 2, 3, 4, 5]
print(list(pairwise(data))) # [(1, 2), (2, 3), (3, 4), (4, 5)]
# Compute differences between consecutive values
prices = [100, 105, 98, 112, 107, 115]
changes = [curr - prev for prev, curr in pairwise(prices)]
print(changes) # [5, -7, 14, -5, 8]
# Detect if sequence is sorted
def is_sorted(iterable):
return all(a <= b for a, b in pairwise(iterable))
print(is_sorted([1, 2, 3, 4, 5])) # True
print(is_sorted([1, 3, 2, 4, 5])) # False
# Count direction changes in a stock price series
prices = [100, 105, 103, 108, 107, 112, 110, 115]
directions = ['up' if b > a else 'down' for a, b in pairwise(prices)]
print(directions) # ['up', 'down', 'up', 'down', 'up', 'down', 'up']
For Python < 3.10, here is the equivalent:
from itertools import tee
def pairwise(iterable):
a, b = tee(iterable)
next(b, None) # advance b by one
return zip(a, b)
batched(iterable, n) - Split Into Fixed-Size Chunks (Python 3.12+)
batched yields tuples of exactly n elements, with the last batch being smaller if the iterable doesn’t divide evenly.
from itertools import batched
data = range(1, 12)
for batch in batched(data, 3):
print(batch)
# (1, 2, 3)
# (4, 5, 6)
# (7, 8, 9)
# (10, 11) ← last batch is smaller
# Real use: batch API calls to avoid rate limits
player_ids = list(range(1, 26)) # 25 player IDs
for batch in batched(player_ids, n=5):
print(f"Fetching players: {batch}")
# fetch_players(batch) ← 5 at a time instead of 25 at once
For Python < 3.12, the manual equivalent:
from itertools import islice
def batched(iterable, n):
it = iter(iterable)
while chunk := tuple(islice(it, n)):
yield chunk
tee(iterable, n=2) - Clone an Iterator
tee creates n independent copies of a single iterator. This lets you iterate over the same sequence multiple times from different starting points.
from itertools import tee
it = iter([1, 2, 3, 4, 5])
a, b = tee(it)
print(list(a)) # [1, 2, 3, 4, 5]
print(list(b)) # [1, 2, 3, 4, 5] ← independent copy
# IMPORTANT: After tee, don't use the original iterator
# Using `it` after tee() produces undefined behaviour
# Use case: compute multiple statistics in one pass
from itertools import tee
def describe(iterable):
a, b, c = tee(iterable, 3)
values = list(a)
n = len(values)
total = sum(b)
mx = max(c)
return {"count": n, "sum": total, "max": mx, "mean": total / n}
print(describe([67, 82, 45, 95, 71, 88]))
# {'count': 6, 'sum': 448, 'max': 95, 'mean': 74.67}
Memory warning:
teestores values internally so that both copies can access them independently. If one copy advances far ahead of the other, the stored values accumulate in memory. For large datasets, prefer materializing to a list.
Part 3: Combinatoric Iterators
These are the functions that generate structured combinations of items. They’re invaluable for testing, optimization, search, and mathematical work.
product(*iterables, repeat=1) - Cartesian Product
product generates the Cartesian product of iterables - every possible combination of one element from each iterable. It’s the equivalent of nested for loops.
from itertools import product
# All combinations of colours and sizes
colours = ['red', 'blue', 'green']
sizes = ['S', 'M', 'L']
for colour, size in product(colours, sizes):
print(f"{colour}-{size}", end=" ")
# red-S red-M red-L blue-S blue-M blue-L green-S green-M green-L
# The `repeat` argument - same iterable crossed with itself
# All 2-digit combinations of 0 and 1 (binary numbers)
binary_pairs = list(product([0, 1], repeat=2))
print(binary_pairs) # [(0, 0), (0, 1), (1, 0), (1, 1)]
# Generate all possible test inputs
def test_matrix(input_lists):
return product(*input_lists)
params = [
['GET', 'POST'], # HTTP method
['/api/v1', '/api/v2'],# endpoint version
[True, False], # authenticated
]
for method, version, auth in test_matrix(params):
print(f"{method} {version} auth={auth}")
# GET /api/v1 auth=True
# GET /api/v1 auth=False
# GET /api/v2 auth=True
# ... (8 total combinations)
permutations(iterable, r=None) - Ordered Arrangements
permutations yields all possible ordered arrangements of r items from the iterable. Order matters: (A, B) and (B, A) are different permutations.
from itertools import permutations
players = ['Rohit', 'Virat', 'Dhoni']
# All arrangements of all 3 players
for perm in permutations(players):
print(perm)
# ('Rohit', 'Virat', 'Dhoni')
# ('Rohit', 'Dhoni', 'Virat')
# ('Virat', 'Rohit', 'Dhoni')
# ('Virat', 'Dhoni', 'Rohit')
# ('Dhoni', 'Rohit', 'Virat')
# ('Dhoni', 'Virat', 'Rohit')
# r=2 - all possible batting pairs (order matters: opening vs #3)
batting_pairs = list(permutations(players, r=2))
print(batting_pairs)
# [('Rohit','Virat'), ('Rohit','Dhoni'), ('Virat','Rohit'),
# ('Virat','Dhoni'), ('Dhoni','Rohit'), ('Dhoni','Virat')]
# Count: n! / (n-r)!
# permutations(10 players, r=3): 10 * 9 * 8 = 720 arrangements
print(f"Total arrangements: {len(list(permutations('ABCD', 2)))}") # 12
Real use case: checking all possible evaluation orders:
from itertools import permutations
steps = ['load_data', 'validate', 'transform', 'save']
# Find which ordering doesn't cause dependency errors
for order in permutations(steps):
if is_valid_order(order): # your dependency check
print(f"Valid order: {order}")
break
combinations(iterable, r) - Unordered Selections (No Repeats)
combinations yields all possible selections of r items where order does NOT matter and each item can only be used once. (A, B) and (B, A) are the same combination - only (A, B) is produced.
from itertools import combinations
squad = ['Rohit', 'Virat', 'Dhoni', 'Jadeja', 'Bumrah']
# Choose 3 from 5 - order doesn't matter
playing_11_selections = list(combinations(squad, 3))
for combo in playing_11_selections:
print(combo)
# ('Rohit', 'Virat', 'Dhoni')
# ('Rohit', 'Virat', 'Jadeja')
# ('Rohit', 'Virat', 'Bumrah')
# ... (10 total)
print(f"Ways to choose 3 from 5: {len(playing_11_selections)}") # 10
# Feature selection in ML - test every pair of features
features = ['age', 'salary', 'experience', 'score']
for f1, f2 in combinations(features, 2):
print(f"Testing pair: ({f1}, {f2})")
# Testing pair: (age, salary)
# Testing pair: (age, experience)
# ... (6 pairs total)
# Find all triangle sides from a list of lengths
lengths = [3, 4, 5, 6, 7]
triangles = [
combo for combo in combinations(lengths, 3)
if combo[0] + combo[1] > combo[2] # triangle inequality
]
print(f"Valid triangles: {triangles}")
combinations_with_replacement(iterable, r) - Unordered Selections (With Repeats)
Like combinations, but each item can appear more than once in a selection.
from itertools import combinations_with_replacement
# Toss a coin 3 times - how many distinct outcomes by count?
outcomes = list(combinations_with_replacement(['H', 'T'], 3))
print(outcomes)
# [('H', 'H', 'H'), ('H', 'H', 'T'), ('H', 'T', 'T'), ('T', 'T', 'T')]
# Only 4 distinct "types" of 3-toss outcome by composition (not sequence)
# Compare with combinations (no repeat) - shows the difference
from itertools import combinations
print(list(combinations(['H', 'T'], 3)))
# [('H', 'T')] ← only one selection of 3 from 2 without repetition? Actually 0 exist for r > n
# Better example: choosing 2 from 3 with and without replacement
items = ['A', 'B', 'C']
print("Without replacement:", list(combinations(items, 2)))
# [('A', 'B'), ('A', 'C'), ('B', 'C')]
print("With replacement: ", list(combinations_with_replacement(items, 2)))
# [('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'B'), ('B', 'C'), ('C', 'C')]
# Count: C(n+r-1, r) for with_replacement vs C(n, r) without
Building Pipelines - Putting It All Together
The real power of itertools shows up when you chain multiple functions together into a data pipeline. Because every function returns an iterator, you can compose them with essentially zero memory overhead.
Pipeline 1: Log File Analysis
Parse a large log file, filter for errors, extract fields, group by hour.
from itertools import groupby, islice
import re
def parse_log_line(line):
"""Parse a log line into a dict."""
pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\] (.+)'
match = re.match(pattern, line.strip())
if match:
return {"timestamp": match[1], "level": match[2], "message": match[3]}
return None
def stream_logs(filepath):
"""Lazy generator over log lines."""
with open(filepath) as f:
for line in f:
record = parse_log_line(line)
if record:
yield record
def only_errors(records):
return filter(lambda r: r["level"] == "ERROR", records)
def get_hour(record):
return record["timestamp"][:13] # "2024-01-15 14"
def analyze_errors(filepath):
records = stream_logs(filepath)
errors = only_errors(records)
sorted_errors = sorted(errors, key=get_hour) # groupby needs sorted input
for hour, group in groupby(sorted_errors, key=get_hour):
error_list = list(group)
print(f"{hour}:xx - {len(error_list)} errors")
for e in islice(error_list, 3): # show first 3 per hour
print(f" {e['message']}")
Pipeline 2: Cricket Stats Rolling Average
Compute a 5-match rolling batting average using pairwise-style windowing:
from itertools import islice, accumulate
from collections import deque
def rolling_average(scores, window=5):
"""Yield the rolling average over the last `window` scores."""
buf = deque(maxlen=window)
for score in scores:
buf.append(score)
if len(buf) == window:
yield round(sum(buf) / window, 2)
rohit_scores = [45, 67, 12, 89, 34, 76, 55, 43, 98, 21, 67, 82]
averages = list(rolling_average(rohit_scores, window=3))
print("3-match rolling avg:", averages)
# [41.33, 56.0, 45.0, 66.33, 55.0, 58.0, 65.33, 54.0, 62.0, 56.67]
Pipeline 3: E-Commerce Product Report
from itertools import chain, groupby, accumulate, islice
from operator import itemgetter
products = list(chain(
[{"cat": "Electronics", "name": "Laptop", "price": 75000, "sold": 12}],
[{"cat": "Electronics", "name": "Phone", "price": 25000, "sold": 45}],
[{"cat": "Clothing", "name": "T-Shirt", "price": 599, "sold": 200}],
[{"cat": "Clothing", "name": "Jeans", "price": 1299, "sold": 89}],
[{"cat": "Books", "name": "Python", "price": 499, "sold": 150}],
))
# Sort by category, then by revenue within category
sorted_products = sorted(products, key=lambda p: (p["cat"], -p["price"] * p["sold"]))
# Group by category and summarize
for category, group in groupby(sorted_products, key=itemgetter("cat")):
items = list(group)
revenues = [p["price"] * p["sold"] for p in items]
total = sum(revenues)
print(f"\n{category}:")
for item, rev in zip(items, revenues):
print(f" {item['name']:<12} Rs.{rev:>10,}")
print(f" {'TOTAL':<12} Rs.{total:>10,}")
Practice Problems
Work through these problems before looking at the solutions. Each one targets a specific part of the module.
Problem 1 - Running Maximum
Task: Given a list of daily temperatures, use accumulate to compute the running maximum temperature seen so far for each day.
Input: [22, 28, 25, 31, 29, 35, 30, 33]
Output: [22, 28, 28, 31, 31, 35, 35, 35]
Solution:
from itertools import accumulate
temps = [22, 28, 25, 31, 29, 35, 30, 33]
running_max = list(accumulate(temps, func=max))
print(running_max)
# [22, 28, 28, 31, 31, 35, 35, 35]
Problem 2 - Round-Robin Tournament Fixtures
Task: Given a list of 4 cricket teams, generate all possible match fixtures using combinations. Each pair plays exactly once.
Teams: ['MI', 'CSK', 'RCB', 'KKR']
Output: ('MI', 'CSK'), ('MI', 'RCB'), ('MI', 'KKR'), ('CSK', 'RCB'), ...
Solution:
from itertools import combinations
teams = ['MI', 'CSK', 'RCB', 'KKR']
fixtures = list(combinations(teams, 2))
print(f"Total fixtures: {len(fixtures)}") # 6
for match_num, (team_a, team_b) in enumerate(fixtures, start=1):
print(f"Match {match_num}: {team_a} vs {team_b}")
# Match 1: MI vs CSK
# Match 2: MI vs RCB
# Match 3: MI vs KKR
# Match 4: CSK vs RCB
# Match 5: CSK vs KKR
# Match 6: RCB vs KKR
Problem 3 - Flatten a Nested List
Task: Flatten a list of lists (one level deep) using chain.from_iterable.
Input: [[1, 2, 3], [4, 5], [], [6, 7, 8, 9]]
Output: [1, 2, 3, 4, 5, 6, 7, 8, 9]
Solution:
from itertools import chain
nested = [[1, 2, 3], [4, 5], [], [6, 7, 8, 9]]
flat = list(chain.from_iterable(nested))
print(flat) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
# Bonus: verify empty sublists are handled gracefully (they are)
Problem 4 - Paginate a Large Dataset
Task: Write a function paginate(data, page_size) using islice that returns a generator. Each call to next() on the generator should yield one page of data as a list.
Solution:
from itertools import islice
def paginate(data, page_size):
it = iter(data)
while True:
page = list(islice(it, page_size))
if not page:
break
yield page
data = list(range(1, 23)) # 22 items
pages = list(paginate(data, page_size=5))
for i, page in enumerate(pages, 1):
print(f"Page {i}: {page}")
# Page 1: [1, 2, 3, 4, 5]
# Page 2: [6, 7, 8, 9, 10]
# Page 3: [11, 12, 13, 14, 15]
# Page 4: [16, 17, 18, 19, 20]
# Page 5: [21, 22]
Problem 5 - Find First Duplicate in a Stream
Task: Using pairwise (or tee), find the first value that appears consecutively in a list.
Input: [1, 2, 3, 3, 4, 5, 5, 6]
Output: 3 (first value that equals the next value)
Solution:
from itertools import pairwise # Python 3.10+
def first_consecutive_duplicate(seq):
for a, b in pairwise(seq):
if a == b:
return a
return None
data = [1, 2, 3, 3, 4, 5, 5, 6]
print(first_consecutive_duplicate(data)) # 3
# Python < 3.10 version using tee:
from itertools import tee
def first_consecutive_duplicate_v2(seq):
a, b = tee(seq)
next(b, None)
for x, y in zip(a, b):
if x == y:
return x
return None
Problem 6 - Group and Summarize Orders
Task: Given a list of order records (already sorted by status), use groupby to count and total the orders in each status group.
orders = [
{"id": 1, "status": "delivered", "amount": 1200},
{"id": 2, "status": "delivered", "amount": 850},
{"id": 3, "status": "delivered", "amount": 2100},
{"id": 4, "status": "pending", "amount": 500},
{"id": 5, "status": "pending", "amount": 750},
{"id": 6, "status": "returned", "amount": 1500},
]
Solution:
from itertools import groupby
from operator import itemgetter
for status, group in groupby(orders, key=itemgetter("status")):
order_list = list(group)
count = len(order_list)
total = sum(o["amount"] for o in order_list)
print(f"{status:12}: {count} orders, Rs.{total:,} total")
# delivered : 3 orders, Rs.4,150 total
# pending : 2 orders, Rs.1,250 total
# returned : 1 orders, Rs.1,500 total
Problem 7 - Generate a Test Matrix
Task: You are testing a login function. Using product, generate all combinations of these test parameters and count them.
usernames = ["valid_user", ""] # 2 values
passwords = ["correct_pw", "wrong_pw", ""] # 3 values
remember_me = [True, False] # 2 values
# Expected: 2 * 3 * 2 = 12 test cases
Solution:
from itertools import product
usernames = ["valid_user", ""]
passwords = ["correct_pw", "wrong_pw", ""]
remember_me = [True, False]
test_cases = list(product(usernames, passwords, remember_me))
print(f"Total test cases: {len(test_cases)}") # 12
for i, (user, pw, rem) in enumerate(test_cases, 1):
user_repr = repr(user) if not user else user
pw_repr = repr(pw) if not pw else "***"
print(f" TC{i:02d}: user={user_repr:<15} pw={pw_repr:<12} remember={rem}")
Problem 8 - Consecutive Price Changes
Task: Given a list of daily stock closing prices, use pairwise to compute the percentage change between each consecutive pair. Then use takewhile to find how many consecutive days a stock was rising from the start.
prices = [100, 105, 108, 107, 112, 115, 110, 118]
Solution:
from itertools import pairwise, takewhile
prices = [100, 105, 108, 107, 112, 115, 110, 118]
# Percentage changes
changes = [round((b - a) / a * 100, 2) for a, b in pairwise(prices)]
print("Daily % changes:", changes)
# [5.0, 2.86, -0.93, 4.67, 2.68, -4.35, 7.27]
# Days rising from start (takewhile positive change)
rising_days = list(takewhile(lambda x: x > 0, changes))
print(f"Consecutive rising days from start: {len(rising_days)}") # 2
print(f"Changes during rise: {rising_days}") # [5.0, 2.86]
Problem 9 - Interleave Two Lists
Task: Without zip, use chain and zip_longest together to interleave two lists element by element, even if they have different lengths.
Input: [1, 2, 3, 4], ['a', 'b', 'c']
Output: [1, 'a', 2, 'b', 3, 'c', 4]
Solution:
from itertools import chain, zip_longest
def interleave(a, b, fillvalue=None):
"""
Interleave two iterables.
Extra elements from the longer one appear at the end.
"""
zipped = zip_longest(a, b, fillvalue=fillvalue)
return [x for pair in zipped for x in pair if x is not fillvalue]
print(interleave([1, 2, 3, 4], ['a', 'b', 'c']))
# [1, 'a', 2, 'b', 3, 'c', 4]
# Alternative: chain after zip
def interleave_v2(a, b):
pairs = zip_longest(a, b)
return list(chain.from_iterable(
(x for x in pair if x is not None) for pair in pairs
))
print(interleave_v2([1, 2, 3, 4], ['a', 'b', 'c']))
# [1, 'a', 2, 'b', 3, 'c', 4]
Summary - When to Use What
┌──────────────────────────────────────────────────────────────────────────┐
│ ITERTOOLS DECISION GUIDE │
├────────────────────────────┬─────────────────────────────────────────────┤
│ I want to... │ Use... │
├────────────────────────────┼─────────────────────────────────────────────┤
│ Count from N forever │ count(start, step) │
│ Loop a list forever │ cycle(iterable) │
│ Repeat a value N times │ repeat(value, times) │
├────────────────────────────┼─────────────────────────────────────────────┤
│ Flatten nested iterables │ chain.from_iterable(nested) │
│ Concatenate iterables │ chain(a, b, c) │
│ Slice an iterator lazily │ islice(it, start, stop, step) │
│ Zip unequal iterables │ zip_longest(a, b, fillvalue=x) │
├────────────────────────────┼─────────────────────────────────────────────┤
│ Running totals/max/etc │ accumulate(data, func=max) │
│ Take while condition holds │ takewhile(pred, it) │
│ Skip while condition holds │ dropwhile(pred, it) │
│ Keep falsy items │ filterfalse(pred, it) │
│ Boolean masking │ compress(data, selectors) │
│ Group by key │ groupby(sorted_data, key=fn) │
│ Apply func to arg tuples │ starmap(func, arg_tuples) │
│ Sliding pairs │ pairwise(iterable) │
│ Split into chunks │ batched(it, n) │
│ Clone an iterator │ tee(it, n) │
├────────────────────────────┼─────────────────────────────────────────────┤
│ All ordered arrangements │ permutations(it, r) │
│ All unordered selections │ combinations(it, r) │
│ Selections with repeats │ combinations_with_replacement(it, r) │
│ Cartesian product │ product(a, b, repeat=n) │
└────────────────────────────┴─────────────────────────────────────────────┘
GOLDEN RULES
────────────
1. Always sort before groupby
2. Always guard infinite iterators with islice
3. Never reuse an exhausted iterator
4. Don't use the original iterator after tee()
5. Wrap in list() only when you need random access or multiple passes
6. Prefer generator pipelines for large/streaming data
End of Guide - Python’s itertools
Codeverra - codeverra.com
