479M Tuples Crashed a Pod — Python itertools Permutations
itertools.permutations(range(12)) materializes 479M tuples requiring 72 GB heap, crashing a K8s pod.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- itertools builds lazy C iterators that produce values on demand — no memory allocation for intermediate lists
- Key tools: chain, chain.from_iterable, product, permutations, groupby, accumulate, tee, pairwise (3.10+), batched (3.12+), zip_longest
- Single-consumption rule: itertools objects exhaust after one pass — materialise to list only when you have confirmed the data fits in memory
- Performance: itertools functions run at C speed; the win over Python loops is largest for filter and map patterns, smallest for simple traversals — always benchmark on your actual data
- Production trap: groupby groups consecutive keys only — sort by key first or you get duplicate groups and silently wrong aggregations
- Biggest mistake: tee with divergent consumers causes unbounded buffering — use list() if branches advance unevenly
- Combinatorics trap: permutations(12, 12) produces 479 million tuples at ~152 bytes each on 64-bit CPython — always check math.perm/comb before any list() call
Itertools is not a magic wand. It's a toolbelt for building iterators without burning RAM. The Python standard library gives you lists, dicts, and sets — data structures that live in memory all at once. Itertools gives you iterators that yield one element at a time, on demand.
Why does that matter? Because real data pipelines choke on memory. You load a CSV with 10 million rows, process it, filter it, transform it — and your machine starts swapping to disk. Itertools lets you chain operations: map, filter, groupby, chain — all evaluated lazily. The data flows through the pipeline one chunk at a time.
The payoff: you can process datasets larger than your available RAM. You write less code, and that code runs faster because you avoid building intermediate lists. When you hit a performance wall, the first question should be: 'Am I holding too much in memory?' The second: 'Can itertools fix this?'
This isn't academic. Every senior engineer I know reaches for itertools when the data gets real. It's not about elegance. It's about not crashing your production server at 3 AM.
Imagine a library that holds every book ever written. An eager librarian photocopies every book before you ask for anything — the room fills with paper before you read a word. A lazy librarian hands you one page at a time, printing the next only when you ask for it. itertools is the lazy librarian. It never produces the next value until your code specifically requests it, which means it never wastes memory generating items you never actually read. The moment you stop asking, it stops working. That single property — compute only what is needed, only when it is needed — is what makes itertools the right tool when your data is large, your memory is limited, or your search space is combinatorially enormous.
Python lists are great until they're not. The moment your data grows to millions of rows — log files, sensor streams, combinatoric search spaces — loading everything into memory stops being clever and starts being catastrophic. itertools is the standard library's answer to that problem: a collection of memory-efficient, fast iterator-building blocks that the CPython team wrote in C. Serious Python engineers reach for it constantly, yet most tutorials only scratch its surface.
The real problem itertools solves is the hidden cost of materialisation. Every time you write [item for item in range(10_000_000)] you are allocating a list in heap memory. With itertools you get a lazy pipeline — a chain of objects that produce values on demand, one at a time. This is not academic niceness. It is the difference between a data pipeline that processes a 10-million-row sensor stream in constant memory and one that exhausts a container's RAM allocation in seconds.
The module has also grown meaningfully in recent Python releases. Python 3.10 added pairwise, which eliminates a classic zip(it, it[1:]) hack that every codebase reimplemented slightly differently. Python 3.12 added batched, which finally gives you a canonical way to chunk an iterator into fixed-size groups without materialising it first. If your itertools knowledge predates 3.10, there are tools in the module right now that replace code you wrote yourself.
By the end of this article you will understand how itertools works at the iterator-protocol level, know exactly which tool to reach for in each scenario, avoid the gotchas that cause silent data corruption and OOM crashes in production, and be able to answer the interview questions that separate itertools users from itertools understanders.
What itertools.permutations Actually Does
itertools.permutations generates all possible ordered arrangements of an input iterable, selecting r elements per tuple. The core mechanic is combinatorial: for n elements with r selections, it yields n! / (n-r)! tuples. This is not random sampling — it's exhaustive enumeration, computed lazily via a backtracking algorithm that swaps elements in a list internally.
Key property: permutations treats elements as distinct by position, not value. If the input contains duplicates, the output will contain duplicate permutations. The algorithm runs in O(n! / (n-r)!) time and O(n) auxiliary space for the internal state. For n=12, r=12, that's 479M tuples — each a 12-element tuple. Materializing that list consumes ~46 GB of memory in CPython.
Use permutations when you need every possible ordering for brute-force search, test case generation, or combinatorial validation. Never use it on large inputs without an explicit r << n or a break condition. In production, it's a common source of silent OOM kills because the lazy iterator hides the true cost until iteration begins.
How itertools Actually Works: Lazy Evaluation Under the Hood
Every object returned by itertools implements Python's iterator protocol — __iter__ and __next__. When you call itertools.chain(a, b) you do not get a new list. You get a small C object that holds references to a and b and advances an internal pointer each time __next__ is called. No data is copied. Nothing is computed until forced.
This is fundamentally different from list comprehensions or list(map(...)). Those are eager — they compute everything immediately and store it in heap memory. itertools objects are lazy — they compute nothing until a for loop, next(), or a consuming function like list() or sum() forces the next value out.
The performance implication is dramatic in the right scenarios. itertools.chain holds two references — a 56-byte C object regardless of input size. The equivalent list_a + list_b allocates a brand-new list proportional to the total number of elements. For infinite sequences like itertools.count(), eager evaluation would never terminate.
One nuance that causes silent production bugs: because these objects are stateful one-directional machines, they can only be consumed once. After you have iterated through an itertools.chain object it is exhausted. Calling list() on it a second time returns an empty list with no error, no warning, and no indication that anything went wrong. If your pipeline produces empty results and you cannot find the bug, a double-consumption is the first thing to check.
The right pattern when you need multiple passes: either recreate the iterator from its source for each pass, or materialise to a list once — but only after confirming the data volume fits in memory. For large data where neither is acceptable, restructure the algorithm to make a single pass.
import itertools import sys # ── 1. Memory footprint: eager vs lazy ─────────────────────────────────────── # The eager list allocates every integer object reference upfront. # The lazy iterator is a fixed-size C object regardless of how many values it produces. eager_numbers = list(range(1_000_000)) lazy_numbers = itertools.islice(itertools.count(0), 1_000_000) print(f"Eager list size : {sys.getsizeof(eager_numbers):>12,} bytes") print(f"Lazy iterator : {sys.getsizeof(lazy_numbers):>12,} bytes") print(f"Memory ratio : {sys.getsizeof(eager_numbers) // sys.getsizeof(lazy_numbers):>12,}x more memory for eager") # ── 2. Single-consumption: the most common itertools production bug ────────── # There is no error, no warning — just empty results on the second pass. it = itertools.chain([1, 2, 3], [4, 5, 6]) first_pass = list(it) second_pass = list(it) # exhausted — always empty print(f"\nFirst pass : {first_pass}") print(f"Second pass : {second_pass} <-- empty, not a bug — expected single-consumption behaviour") # ── 3. The correct pattern: recreate from source for each pass ─────────────── # When materialising is not an option, wrap in a factory and call fresh. def make_pipeline(): """Returns a fresh iterator every time. No state shared between calls.""" return itertools.chain([1, 2, 3], [4, 5, 6]) for pass_number in range(1, 4): result = list(make_pipeline()) print(f"Pass {pass_number}: {result}") # Always [1, 2, 3, 4, 5, 6] # ── 4. chain object size: constant regardless of input ─────────────────────── chain_small = itertools.chain(range(10), range(10)) chain_large = itertools.chain(range(10_000_000), range(10_000_000)) print(f"\nchain over 20 items : {sys.getsizeof(chain_small)} bytes") print(f"chain over 20,000,000 items: {sys.getsizeof(chain_large)} bytes") print("Both the same — chain holds references, not copies.")
Infinite Iterators, Slicing and Chaining: Building Real Data Pipelines
itertools gives you three infinite iterators: count, cycle, and repeat. These sound dangerous but they are the backbone of real patterns: round-robin load balancing with cycle, default-value padding with repeat, and auto-incrementing IDs with count. The discipline is always pairing them with islice or takewhile to impose a finite boundary. An infinite iterator without a termination condition is an infinite loop waiting to happen.
chain and chain.from_iterable deserve attention because they are often the glue between pipeline stages. chain(*list_of_lists) unpacks at call time — all sub-iterables must exist before the call. chain.from_iterable(generator_of_lists) is fully lazy — it does not touch the next sub-iterable until the previous one is exhausted. That distinction is critical when sub-iterables are expensive to create: open file handles, database cursors, or network connections.
The file descriptor story is a real one. If you build a list of open file handles and then pass it to chain(*handles), all handles are open simultaneously at call time. With thousands of files that hits the OS file descriptor limit. chain.from_iterable with a generator that opens files one at a time defers each open call until needed, keeping at most one or two file handles alive at any moment.
islice is your lazy version of Python's slice syntax. It cannot accept negative indices — it does not know the total length — but for extracting a window from a stream it is indispensable. Pair it with dropwhile and takewhile to express WHERE-clause style filters over any iterable without materialising it.
zip_longest handles the case where two streams have different lengths. Standard zip stops at the shorter one silently, which is a source of data loss bugs when merging streams of different provenance. zip_longest fills missing values with a configurable fillvalue, making the length mismatch explicit rather than silent.
import itertools # ── 1. count + islice: bounded ID generator ────────────────────────────────── # count() is infinite. islice() imposes the finite boundary. # Never use count() in a for loop without a termination condition. def generate_ids(prefix: str, count: int): for i in itertools.islice(itertools.count(1), count): yield f"{prefix}-{i:07d}" ids = list(generate_ids("JOB", 5)) print(f"Generated IDs: {ids}") # ── 2. cycle + islice: round-robin load balancer ───────────────────────────── servers = ['api-a', 'api-b', 'api-c'] requests = [f"req-{i}" for i in range(9)] for request, server in zip(requests, itertools.cycle(servers)): print(f"{request} -> {server}") # ── 3. chain.from_iterable: lazy log file merger ───────────────────────────── # WRONG pattern: chain(*handles) — all files opened at once at call time # handles = [open(f) for f in file_list] # all open simultaneously # merged = itertools.chain(*handles) # file descriptor exhaustion risk # CORRECT pattern: chain.from_iterable with a generator that opens lazily # Each file is opened only when the previous one is exhausted. def open_files_lazily(file_paths): """Opens and yields lines from each file, one file at a time.""" for path in file_paths: try: with open(path, 'r', encoding='utf-8') as f: yield from f except FileNotFoundError: pass # skip missing log files gracefully # Usage: processes 10,000 log files with at most 1 open file descriptor at a time # for line in open_files_lazily(all_log_paths): # process(line) # ── 4. takewhile + dropwhile: conditional stream windowing ─────────────────── data = [0, 0, 0, 1, 2, 3, 0, 4, 5] take = list(itertools.takewhile(lambda x: x == 0, data)) # leading zeros skip = list(itertools.dropwhile(lambda x: x == 0, data)) # skip leading zeros print(f"\nOriginal : {data}") print(f"Leading zeros : {take}") print(f"After zeros : {skip}") # ── 5. zip_longest: safe merging of unequal-length streams ─────────────────── # Standard zip silently truncates to the shorter sequence. # zip_longest makes the length mismatch explicit and fills gaps. stream_a = [1, 2, 3, 4, 5] stream_b = ['a', 'b', 'c'] # shorter — zip would silently drop items 4 and 5 print("\nWith zip (silent truncation):") for pair in zip(stream_a, stream_b): print(f" {pair}") print("\nWith zip_longest (explicit fill):") for pair in itertools.zip_longest(stream_a, stream_b, fillvalue='MISSING'): print(f" {pair}")
pairwise and batched: The Modern itertools Tools You Should Be Using
Python 3.10 added pairwise. Python 3.12 added batched. Both solve patterns that every Python codebase had implemented manually for years, usually in subtly wrong ways. If your itertools knowledge predates these versions, there is code in your codebase right now that these tools replace cleanly.
pairwise(iterable) returns successive overlapping pairs: (s[0], s[1]), (s[1], s[2]), (s[2], s[3])... The manual implementation was zip(it, it[1:]) which requires materialising the iterable twice, or the more arcane zip(it, islice(it, 1, None)) which is stateful and confusing to read. pairwise is lazy, needs no materialisation, and communicates intent clearly. It is the right tool for consecutive difference calculations, time-series delta analysis, and detecting transitions between states in a stream.
batched(iterable, n) splits an iterable into chunks of at most n items each, yielding each chunk as a tuple. The final chunk may be shorter than n if the iterable does not divide evenly. Before Python 3.12 this required a manual islice-in-a-while-loop pattern that appears in countless Stack Overflow answers, each with slightly different edge case handling for the final short chunk. batched handles it correctly by design.
Both tools are available only in the standard library from their respective Python versions. If you are running Python 3.9 or earlier you will need to implement equivalents manually — the code examples below show the pre-3.10 and pre-3.12 patterns alongside the canonical versions so you can recognise both forms.
import itertools import sys PYTHON_VERSION = sys.version_info # ── pairwise (Python 3.10+) ─────────────────────────────────────────────────── # Returns successive overlapping pairs from an iterable. # Essential for time-series deltas, state transitions, consecutive comparisons. if PYTHON_VERSION >= (3, 10): # Canonical — clear, lazy, correct prices = [10.0, 10.5, 9.8, 11.2, 11.0, 12.3] deltas = [(b - a) for a, b in itertools.pairwise(prices)] print(f"Prices : {prices}") print(f"Deltas : {[round(d, 2) for d in deltas]}") # State transition detection in a log stream states = ['idle', 'idle', 'processing', 'processing', 'idle', 'error'] transitions = [ (prev, curr) for prev, curr in itertools.pairwise(states) if prev != curr ] print(f"\nState transitions: {transitions}") else: print("pairwise requires Python 3.10+ — using manual equivalent") def pairwise_compat(iterable): """Pre-3.10 equivalent. Materialises one copy.""" a, b = itertools.tee(iterable) next(b, None) return zip(a, b) # ── batched (Python 3.12+) ──────────────────────────────────────────────────── # Chunks an iterable into tuples of at most n items. # Final chunk may be shorter if the iterable does not divide evenly. if PYTHON_VERSION >= (3, 12): records = list(range(1, 11)) # [1, 2, 3, ..., 10] print(f"\nRecords: {records}") print("Batched into groups of 3:") for batch in itertools.batched(records, 3): print(f" {batch}") # Real-world use: chunked API writes def insert_in_chunks(items, chunk_size: int, insert_fn): """Processes items in chunks without materialising the full list.""" for batch in itertools.batched(items, chunk_size): insert_fn(batch) def simulated_db_insert(batch): print(f" Inserting batch of {len(batch)}: {batch}") print("\nChunked DB inserts:") insert_in_chunks(range(1, 8), 3, simulated_db_insert) else: print("batched requires Python 3.12+ — using manual equivalent") def batched_compat(iterable, n): """Pre-3.12 equivalent using islice.""" it = iter(iterable) while True: batch = tuple(itertools.islice(it, n)) if not batch: return yield batch # ── Combining pairwise + batched: sliding window analysis ──────────────────── # Real use case: detect anomalies in sensor readings by comparing consecutive # values in batches, without loading the entire stream. if PYTHON_VERSION >= (3, 12): sensor_readings = [100, 102, 98, 250, 101, 99, 103, 400, 97] threshold = 50 print("\nAnomaly detection (consecutive delta > threshold):") for a, b in itertools.pairwise(sensor_readings): delta = abs(b - a) if delta > threshold: print(f" Anomaly: {a} -> {b} (delta={delta})")
Combinatorics: product, permutations, combinations — Size It Before You Materialise It
itertools provides four combinatoric generators: product, permutations, combinations, and combinations_with_replacement. They are lazy in the same sense as all itertools objects — they produce one tuple at a time on demand. But their output count grows at rates that make laziness irrelevant if you materialise them.
product(n, repeat=r) yields n^r tuples. permutations(n, r) yields n!/(n-r)! tuples. combinations(n, r) yields n!/(r!(n-r)!) tuples. These numbers exceed available RAM well before the input sizes feel dangerous. permutations(12, 12) is 479 million tuples. On 64-bit CPython 3.11, each tuple of 12 elements occupies approximately 152 bytes: a 56-byte tuple header plus 12 internal pointers at 8 bytes each. Small integers in range(12) are interned and share their storage, but the tuple structures themselves are not. Materialising the full set requires approximately 72 GB — on an 8 GB pod that is a death sentence.
The golden rule: never call list() on a combinatoric iterator before computing the expected size with math.perm or math.comb. That computation is O(1) and takes microseconds. The OOM kill is not reversible.
Processing combinatoric output lazily means working with one tuple at a time: filtering with filter() or itertools.filterfalse() directly on the lazy iterator, slicing with islice to take a bounded sample, or using a for loop that breaks early. None of these ever builds the full set in memory. The discarded tuples cost nothing.
import itertools import math import sys # ── 1. Size check before materialising: always do this first ───────────────── n, r = 12, 12 perm_count = math.perm(n, r) # 479,001,600 comb_count = math.comb(n, r) # 1 (choosing all 12 from 12 is one combination) comb_10 = math.comb(n, 10) # 66 (more interesting: choosing 10 from 12) print(f"permutations({n}, {r}) = {perm_count:>15,} tuples") print(f"combinations({n}, {r}) = {comb_count:>15,} tuples") print(f"combinations({n}, 10) = {comb_10:>15,} tuples") # Memory estimate on 64-bit CPython 3.11 # Each tuple of 12 elements: 56 byte header + 12 pointers * 8 bytes = 152 bytes # Small integers (0-11) are interned so no per-tuple allocation for the ints themselves bytes_per_tuple = 56 + (r * 8) print(f"\nBytes per tuple of {r} elements: {bytes_per_tuple}") print(f"Memory for list(permutations({n},{r})): ~{perm_count * bytes_per_tuple / 1e9:.1f} GB") print(f"Memory for list(combinations({n},10)): ~{comb_10 * (56 + 10*8) / 1e6:.1f} MB <- safe") # ── 2. Safe lazy processing: filter directly on the iterator ───────────────── # Rule: filter before you materialise, never after. def is_ascending(t): """True if the tuple values are strictly increasing.""" return all(a < b for a, b in itertools.pairwise(t)) # pairwise from 3.10+ # Process lazily: tuples that don't pass the filter are never stored ascending_count = 0 for perm in itertools.permutations(range(6)): # 720 permutations — safe to iterate fully if is_ascending(perm): ascending_count += 1 print(f"\nAscending permutations of range(6): {ascending_count}") # should be 1: (0,1,2,3,4,5) # ── 3. islice: bounded sampling from a huge combinatoric space ─────────────── print("\nFirst 5 permutations of range(12) (sample only — not materialised fully):") for p in itertools.islice(itertools.permutations(range(12)), 5): print(f" {p}") # ── 4. product: more predictable growth than permutations ──────────────────── # product(n, repeat=r) = n^r — exponential but predictable and often much smaller config_values = { 'workers': [2, 4, 8], 'timeout': [30, 60], 'retries': [1, 3, 5], } keys = list(config_values.keys()) value_lists = list(config_values.values()) grid_size = math.prod(len(v) for v in value_lists) print(f"\nConfig grid size: {grid_size} combinations") print("Configs:") for combo in itertools.product(*value_lists): config = dict(zip(keys, combo)) print(f" {config}") # ── 5. Pre-flight assertion: catch it before it crashes ────────────────────── MAX_SAFE_MATERIALISE = 1_000_000 def safe_permutations(items, r=None): """ Yields permutations lazily after confirming the count is within budget. Raises ValueError before allocating anything if the count exceeds the limit. """ n = len(items) r = r if r is not None else n count = math.perm(n, r) if count > MAX_SAFE_MATERIALISE: raise ValueError( f"permutations({n}, {r}) would produce {count:,} tuples — " f"exceeds safe limit of {MAX_SAFE_MATERIALISE:,}. " f"Use filter() and islice() on the lazy iterator instead." ) return itertools.permutations(items, r) try: result = safe_permutations(list(range(12))) except ValueError as e: print(f"\nPre-flight check caught: {e}")
list() — fine, fits easily.list() call.Performance: When itertools Beats Pure Python and When It Doesn't
itertools functions are implemented in C and avoid Python bytecode overhead per element. The win is real but not uniform — it depends on what the equivalent Python code is and what the bottleneck actually is.
The gains are largest for filter and transform patterns. itertools.compress applies a boolean mask in C with no Python-level per-element call. A list comprehension with a conditional does execute Python bytecode for every element. The difference is measurable and consistent: around 2–3× for compress versus an equivalent list comprehension at scale.
The gains are smallest — or reversed — for random-access patterns on pre-built lists. islice over a list that supports O(1) indexing can be slower than a direct slice, because islice has per-element __next__ overhead that a slice's underlying C memcpy does not. The right comparison is islice over a lazy generator source (where slicing is impossible) versus materialising the generator first and then slicing. In that comparison islice wins substantially.
chain against list concatenation shows a genuine memory win even when total iteration time is similar. The + operator on two lists allocates a third list in one operation — fast, but O(n) memory. chain holds two references and produces values on demand — O(1) memory. For pipelines where memory pressure matters more than raw CPU time, chain is the right choice even when the timing difference is modest.
The broader point: itertools is not a universal speedup. It is a memory management tool that happens to avoid some Python-level overhead as a side effect. Use it when your data is large enough that O(n) memory allocation matters, not as a reflexive replacement for every list comprehension. Always benchmark on your actual data size with timeit before committing to an itertools-heavy approach in a hot path.
import itertools import timeit import sys # ── 1. compress vs list comprehension filter ───────────────────────────────── # compress applies a boolean mask in C — no Python call per element. # List comprehension executes Python bytecode for every element. records = [f"row-{i}" for i in range(500_000)] mask = [i % 3 == 0 for i in range(500_000)] def list_comp_filter(): return [r for r, m in zip(records, mask) if m] def itertools_compress_filter(): return list(itertools.compress(records, mask)) time_comp = timeit.timeit(list_comp_filter, number=10) time_compress = timeit.timeit(itertools_compress_filter, number=10) print("── compress vs list comprehension (500k records) ──") print(f"list comprehension : {time_comp:.4f}s (10 runs)") print(f"itertools.compress : {time_compress:.4f}s (10 runs)") print(f"Speedup : {time_comp / time_compress:.1f}x") # ── 2. chain vs list concatenation ─────────────────────────────────────────── # + operator allocates a new list — O(n) memory, fast CPU. # chain holds references — O(1) memory, similar CPU, no intermediate allocation. list_a = list(range(500_000)) list_b = list(range(500_000)) def eager_plus(): result = list_a + list_b return len(result) # force evaluation def lazy_chain(): result = itertools.chain(list_a, list_b) return sum(1 for _ in result) # force evaluation without materialising time_plus = timeit.timeit(eager_plus, number=20) time_chain = timeit.timeit(lazy_chain, number=20) print("\n── chain vs list concatenation (1M elements total) ──") print(f"list_a + list_b : {time_plus:.4f}s (20 runs)") print(f"itertools.chain : {time_chain:.4f}s (20 runs)") print(f"Speed difference : {abs(time_plus - time_chain):.4f}s") print(f"Memory difference : list allocates ~{sys.getsizeof(list_a + list_b):,} bytes extra") print(f" chain allocates ~{sys.getsizeof(itertools.chain(list_a, list_b))} bytes") # ── 3. islice on a generator vs materialise-then-slice ─────────────────────── # islice wins when the source is lazy (generator) — avoids materialisation entirely. # On a pre-built list, direct slicing is faster — no per-element overhead. def make_generator(n): return (x * x for x in range(n)) def materialise_then_slice(n): # Materialise the full generator, then slice return list(make_generator(n))[100:200] def islice_on_generator(n): # Take only elements 100..199 from the generator — never materialises the rest return list(itertools.islice(make_generator(n), 100, 200)) time_mat = timeit.timeit(lambda: materialise_then_slice(500_000), number=50) time_isl = timeit.timeit(lambda: islice_on_generator(500_000), number=50) print("\n── islice vs materialise-then-slice on a generator (500k elements) ──") print(f"Materialise + slice: {time_mat:.4f}s (50 runs)") print(f"islice on generator: {time_isl:.4f}s (50 runs)") print(f"Speedup : {time_mat / time_isl:.1f}x") print("Note: islice wins because it never touches elements 200..499,999") # ── 4. starmap vs map+lambda ───────────────────────────────────────────────── # starmap avoids the lambda call overhead for functions that take multiple args. coords = [(x, y) for x in range(1000) for y in range(10)] def with_lambda(): return list(map(lambda args: args[0] ** 2 + args[1] ** 2, coords)) def with_starmap(): import math return list(itertools.starmap(lambda x, y: x ** 2 + y ** 2, coords)) time_lambda = timeit.timeit(with_lambda, number=50) time_star = timeit.timeit(with_starmap, number=50) print("\n── starmap vs map+lambda (10k two-arg operations) ──") print(f"map + lambda : {time_lambda:.4f}s (50 runs)") print(f"itertools.starmap : {time_star:.4f}s (50 runs)") print(f"Speedup : {time_lambda / time_star:.1f}x")
groupby, tee, and accumulate: Tools That Bite Back
Three itertools tools have subtle behaviours that produce production bugs. Each one has a pattern that looks correct, runs without error, and returns wrong results.
groupby does not group all identical keys together. It groups consecutive identical keys. The name strongly implies SQL GROUP BY behaviour — it is not. If your data has 'engineering' records at positions 1, 3, and 5 interleaved with 'sales' records at positions 2 and 4, groupby produces three groups: one engineering, one sales, one engineering. Not two. The fix is always a sorted() call on the same key immediately before groupby. This is the most common itertools bug in production and the one most likely to go undetected because the output looks plausible.
tee splits a single iterator into n independent iterators. Internally it maintains a linked list of fixed-size chunks — each chunk holds 57 elements in CPython's implementation. Items consumed by both iterators are released. Items consumed by one but not yet the other are buffered. The buffer holds exactly the delta between the faster and slower consumer, not everything the faster consumer has ever seen. This distinction matters: a tee where both consumers advance at similar rates is safe. A tee where one consumer races 100,000 items ahead of the other buffers 100,000 items. Use tee only for lockstep consumers or consumers that stay within a few thousand items of each other.
accumulate computes running aggregates. It works with any binary function, not just addition. Running max, running min, running product — all valid. One important note: Python integers are arbitrary precision, so accumulate with operator.mul does not overflow in the fixed-width sense. What it does produce is factorially growing integers — each multiplication involves larger numbers than the last, and the per-operation cost grows accordingly. For large numeric sequences this can become a CPU and memory bottleneck without any exception being raised.
import itertools import operator # ── 1. groupby: sort first or get duplicate groups ─────────────────────────── data = [ {'department': 'engineering', 'name': 'Alice'}, {'department': 'sales', 'name': 'Bob'}, {'department': 'engineering', 'name': 'Charlie'}, # same dept, not consecutive {'department': 'sales', 'name': 'Diana'}, ] key_func = lambda x: x['department'] print("── groupby WITHOUT sorting (wrong) ──") for dept, group in itertools.groupby(data, key_func): members = [p['name'] for p in group] print(f" {dept}: {members}") # engineering and sales each appear twice — duplicate groups, wrong aggregation print("\n── groupby WITH sorting (correct) ──") sorted_data = sorted(data, key=key_func) for dept, group in itertools.groupby(sorted_data, key_func): members = [p['name'] for p in group] print(f" {dept}: {members}") # Each department appears once with all its members # ── 2. tee: buffer holds the delta between consumers, not everything ────────── # The buffer grows when one consumer advances ahead of the other. # Items consumed by BOTH are released — tee does not hold the entire stream. source = iter(range(10)) t1, t2 = itertools.tee(source, 2) # Advance t1 by 5 items — tee buffers items 0..4 for t2 for _ in range(5): next(t1) # t2 hasn't consumed anything yet — the 5 buffered items are waiting print("\n── tee: consuming the buffered items ──") print(f"t2 sees: {list(t2)}") # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(f"t1 sees remaining: {list(t1)}") # [5, 6, 7, 8, 9] # Safe tee pattern: lockstep consumers via zip source2 = iter(range(8)) t1, t2 = itertools.tee(source2) consecutive_pairs = list(zip(t1, t2)) # Note: this is equivalent to pairwise in Python 3.10+ print(f"\nLockstep tee (consecutive pairs): {consecutive_pairs}") # ── 3. accumulate: arbitrary binary functions, factorially growing integers ── values = [3, 1, 4, 1, 5, 9, 2, 6] # Running sum (default) running_sum = list(itertools.accumulate(values)) print(f"\nRunning sum : {running_sum}") # Running maximum running_max = list(itertools.accumulate(values, max)) print(f"Running max : {running_max}") # Running minimum running_min = list(itertools.accumulate(values, min)) print(f"Running min : {running_min}") # Running product — integers grow factorially in magnitude, not overflowing # (Python has arbitrary-precision integers) but getting expensive to compute running_product = list(itertools.accumulate(values, operator.mul)) print(f"Running prod: {running_product}") print("Note: no overflow — Python ints are arbitrary precision.") print("But: each multiplication involves larger numbers than the last.") print("For large sequences with large values this becomes a CPU cost, not a correctness issue.") # With initial value (Python 3.8+) running_with_initial = list(itertools.accumulate(values, initial=100)) print(f"\nWith initial=100: {running_with_initial}")
What Is Itertools and Why Should You Use It?
Itertools is not a magic wand. It's a toolbelt for building iterators without burning RAM. The Python standard library gives you lists, dicts, and sets — data structures that live in memory all at once. Itertools gives you iterators that yield one element at a time, on demand.
Why does that matter? Because real data pipelines choke on memory. You load a CSV with 10 million rows, process it, filter it, transform it — and your machine starts swapping to disk. Itertools lets you chain operations: map, filter, groupby, chain — all evaluated lazily. The data flows through the pipeline one chunk at a time.
The payoff: you can process datasets larger than your available RAM. You write less code, and that code runs faster because you avoid building intermediate lists. When you hit a performance wall, the first question should be: 'Am I holding too much in memory?' The second: 'Can itertools fix this?'
This isn't academic. Every senior engineer I know reaches for itertools when the data gets real. It's not about elegance. It's about not crashing your production server at 3 AM.
// io.thecodeforge — python tutorial import itertools import tracemalloc def process_with_list(data_stream): # Bad: builds full list in memory all_data = list(data_stream) evens = [x for x in all_data if x % 2 == 0] squares = [x*x for x in evens] return sum(squares) def process_with_itertools(data_stream): # Good: lazy pipeline, constant memory evens = itertools.filterfalse(lambda x: x % 2 != 0, data_stream) squares = map(lambda x: x * x, evens) return sum(squares) # Simulate 10M row stream tracemalloc.start() _ = process_with_list(iter(range(10_000_000))) current, peak = tracemalloc.get_traced_memory() print(f"List version: {peak / 1024 / 1024:.0f} MB peak") tracemalloc.stop() tracemalloc.start() _ = process_with_itertools(iter(range(10_000_000))) current, peak = tracemalloc.get_traced_memory() print(f"Itertools version: {peak / 1024 / 1024:.0f} MB peak")
list() unless you're prepared to hold the entire result set in memory. I've seen production outages from exactly this: someone chains itertools.islice then wraps it in list() on a 50GB log file.The grouper Recipe: Why itertools Recipes Matter More Than the Docs
The standard itertools docs contain a 'recipes' section that most devs ignore. Big mistake. Those recipes are battle-tested patterns for solving real problems. The classic: grouper — splitting an iterable into fixed-size chunks.
You might think: 'I can do that with a loop.' You can. But the loop version builds intermediate lists, handles edge cases poorly (uneven last chunk), and is harder to read. The itertools version is a one-liner using zip([iter(s)]n) that evaluates lazily.
Here's the internals: iter(s) creates one iterator. [iter(s)]n creates a list of n references to that same iterator. zip(...) reads n items from it per iteration. When the iterator runs out mid-chunk, zip drops the incomplete group. If you want to keep the last chunk (even if incomplete), use itertools.zip_longest with a sentinel value.
This pattern shows up everywhere: batching API calls, grouping log entries by hour, splitting a CSV into chunks for parallel processing. Learn it once, use it forever.
// io.thecodeforge — python tutorial import itertools def grouper(iterable, chunk_size, fill_value=None): "Collect data into fixed-length chunks or blocks" # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx args = [iter(iterable)] * chunk_size return itertools.zip_longest(*args, fillvalue=fill_value) # Real scenario: batch API calls api_ids = [101, 202, 303, 404, 505, 606, 707, 808] batch_size = 3 for batch_num, batch in enumerate(grouper(api_ids, batch_size, fill_value=None), 1): clean_ids = [i for i in batch if i is not None] print(f"Batch {batch_num}: {clean_ids}")
filter(None, batch) to strip fill values from incomplete batches without writing a comprehension. None is falsy, so filter drops it automatically.[iter(s)]*n is the trick. Combined with zip or zip_longest, it's the canonical way to batch work without lists.Dealing a Deck of Cards Without State
Card games need shuffles, hands, and deals. The naive approach builds a list, shuffles it in place, then pops cards. That mutates state, breaks if you need deterministic replays, and forces you to copy the deck if you want a fresh game.
itertools fixes this. Use itertools.product(range(4), range(13)) to generate all 52 combos of suits and ranks. Combine with random.Random(seed).sample() to produce a shuffled iterator without modifying the source. Then zip([iter(deck)]n) — the classic grouper pattern — deals n cards per hand in one line.
Because itertools returns iterators, you can replay a seed, peek at cards without consuming, or parallelize multiple deals from the same generator. No mutated list, no deepcopy, no side effects. Production card logic should never touch a global list again.
// io.thecodeforge — python tutorial import itertools import random suits = ['S', 'H', 'D', 'C'] ranks = list(range(1, 14)) # 1=Ace, 11=Jack, etc. def fresh_deck(): return list(itertools.product(ranks, suits)) def deal_hands(seed=42, hand_size=5, num_hands=4): deck = fresh_deck() rng = random.Random(seed) rng.shuffle(deck) it = iter(deck) return [list(itertools.islice(it, hand_size)) for _ in range(num_hands)] hands = deal_hands() for i, hand in enumerate(hands, 1): print(f"Hand {i}: {hand}")
zip([iter(shuffled)]n) to batch-deal any fixed-width data. Works for poker hands, survey responses, or chunked API batches.Building Relay Teams From Swimmer Data
Relay races demand optimal team composition: 4 swimmers, each with a best time in a specific stroke. Brute-forcing all combinations is cheap — itertools.permutations(swimmers, 4) yields only 24 combos for 4 people. But real rosters have 12 swimmers, which explodes to 11,880 possibilities.
Instead, pre-filter. Use itertools.combinations(swimmers, 4) to pick any 4 athletes, then itertools.permutations on each subset to assign strokes. That drops the search space from 11,880 to 495 * 24 = 11,880 (same count, different structure). The real win: you can itertools.islice the permutation generator after finding any time below a threshold, avoiding full enumeration.
Pair with min(gen, key=lambda team: sum(...)) for a single-pass best team. Lazy evaluation means you can abort early, cache partial sums, or parallelize across strokes. No lists, no memory blowup, just pure generator composition until you find your gold medal.
// io.thecodeforge — python tutorial import itertools swimmers = [ ('Alice', {'free': 54.2, 'back': 59.1, 'breast': 63.7, 'fly': 58.0}), ('Bob', {'free': 53.8, 'back': 60.2, 'breast': 65.0, 'fly': 57.4}), ('Carol', {'free': 55.0, 'back': 58.5, 'breast': 62.1, 'fly': 59.2}), ('Dave', {'free': 52.9, 'back': 61.0, 'breast': 64.3, 'fly': 56.8}), ] strokes = ['back', 'breast', 'fly', 'free'] best_time = float('inf') best_team = None for combo in itertools.combinations(swimmers, 4): for perm in itertools.permutations(combo): total = sum(swimmer[1][style] for swimmer, style in zip(perm, strokes)) if total < best_time: best_time = total best_team = list(zip(perm, strokes)) print(f"Best relay team: {best_team}, total: {best_time}")
islice or break early — 11k teams is fine, 11 million kills latency. Always estimate combinatorial explosion before running.combinations for selection and permutations for assignment to keep search lazy and tractable.Recurrence Relations: Why Lazy Sequences Outperform Recursion
Recurrence relations define each term based on previous ones — Fibonacci, factorial, binomial coefficients. Naive recursion recomputes the same subproblems, exploding stack depth. Python’s itertools.accumulate with a 2-element tuple handles recurrence in constant memory and linear time. Instead of recursive calls, you feed the previous state forward as a lazy stream. The function receives the last computed pair and returns the next pair. accumulate never stores the full sequence; it yields one term at a time. This pattern works for any linear recurrence: you control the initial seed and the update rule. The performance gain grows with sequence length because recursion depth becomes O(1) and memory stays O(1). Use this whenever you need a sliding window of past values without materializing the whole list. The trick is pairing — carry the last two terms as a tuple and destructure inside the accumulator function.
// io.thecodeforge — python tutorial from itertools import accumulate, islice def fib_stream(): seed = (0, 1) rule = lambda state: (state[1], state[0] + state[1]) yield from (pair[0] for pair in accumulate(repeat(seed), lambda x, _: rule(x))) first_10 = list(islice(fib_stream(), 10)) print(first_10)
accumulate returns a lazy iterator; calling list() on an infinite generator hangs your process. Always use islice to bound the stream.Analyzing the S&P500: How Itertools Builds Rolling Metrics Without Pandas
Stock markets produce ticks — prices over time. The core analysis is rolling: moving averages, max drawdown, consecutive gains. Pandas handles this with .rolling(). But when you need a lightweight pipeline or process streaming data, itertools wins. itertools.tee duplicates an iterator to compute multiple windows in lockstep. itertools.islice slides the window start. itertools.accumulate tracks running sums for O(1) moving average updates. For max drawdown, combine accumulate with max to keep a running high, then compute the drop from that high at each step. No DataFrame. No materialized lists. Use zip with tee to align shifted windows for simple moving average (SMA). Every metric computes in one pass, O(1) memory. This pattern works directly on live market feeds — a generator yielding price ticks. The most practical function is pairwise (Python 3.10+) for day-over-day changes, or replicate it with tee and islice for daily returns.
// io.thecodeforge — python tutorial from itertools import islice, accumulate, tee def rolling_max_drawdown(prices): it1, it2 = tee(prices, 2) highs = accumulate(it1, max) for price, high in zip(it2, highs): yield high - price # drawdown at each tick sample = [4500, 4510, 4490, 4480, 4520] drawdowns = list(rolling_max_drawdown(iter(sample))) print(drawdowns)
tee requires storing buffer values if the iterators fall out of sync. When using zip with tee, ensure both iterators advance together — never consume one ahead of the other or memory leaks.tee to compute multiple rolling metrics in a single pass over the data.Materialised Combinatorics Blew Up a 16-Node Kubernetes Cluster
list() forces full materialisation of every element the iterator can produce — laziness only applies to the iteration itself, not to any consuming call.list() call returned.filter() directly on the lazy iterator before any consuming call. The fix was to replace list(permutations(...)) with a generator-based consumer that evaluated one permutation at a time, discarding invalid ones immediately without ever building the full set. The team also added a pre-flight check using math.perm to compute and log the expected output size before any combinatoric iterator is constructed.- Always compute expected output size with math.perm or math.comb before calling
list()on any combinatoric iterator. The numbers grow faster than intuition suggests. - Never call
list()on combinatoric iterators unless you have verified the result fits comfortably in your memory budget — with headroom for the rest of the process. - Prefer
filter()oritertools.filterfalse()to reduce the stream lazily before any consuming call. Invalid permutations discarded before materialisation cost nothing. - Add a combinatorics size check as a startup assertion in any service that generates permutations or combinations at runtime.
list(), sum(), min(), max(), or for loop applied to the same itertools object. Restructure into a single pass, or materialise once to a variable and reuse that variable. If memory is tight, recreate the iterator from its source factory for each pass rather than materialising.sorted() call immediately before groupby: for k, g in itertools.groupby(sorted(data, key=func), key=func). The sort must happen on the same key function used for grouping.tee()list() if branches diverge significantly — materialise once, create two independent iterators over the list.list() before confirming size. Compute math.perm(n, r) or math.comb(n, r) immediately and compare against available memory. Switch to a lazy consumer: for item in itertools.islice(permutations(...), chunk_size) processed in a loop, or filter directly on the lazy iterator.python -c "
import itertools
it = itertools.chain([1,2,3], [4,5,6])
print('First:', list(it))
print('Second:', list(it)) # Always empty — iterator exhausted
"grep -n 'list(\|sum(\|min(\|max(' pipeline.py | grep -v '#' — find every consuming call and check whether they share the same iterator variablesource(): return itertools.chain(...) and call source() fresh for each consumer.python -c "
import itertools
data = [('b', 1), ('a', 2), ('b', 3)]
print('Keys without sort:', [k for k, _ in itertools.groupby(data, key=lambda x: x[0])])
data_sorted = sorted(data, key=lambda x: x[0])
print('Keys with sort: ', [k for k, _ in itertools.groupby(data_sorted, key=lambda x: x[0])])
"grep -n 'groupby' src/ — find every groupby call and verify a sorted() call on the same key appears immediately before itpython -c "
import itertools, sys
source = iter(range(100_000))
t1, t2 = itertools.tee(source)
for _ in range(50_000): next(t1) # advance t1 by 50k
print('t1 object size:', sys.getsizeof(t1), 'bytes')
print('t2 object size:', sys.getsizeof(t2), 'bytes')
print('Note: actual buffer lives in shared tee dataobject, not in the iterator objects themselves')
"grep -n 'tee(' src/ — find every tee call and review how far apart the consumers advance before they both complete| Tool | Lazy? | Memory Usage | Output Type | Best For | Watch Out For |
|---|---|---|---|---|---|
| itertools.chain | Yes | O(1) — 56 bytes regardless of input size | Values from inputs in declaration order | Concatenating iterables without allocating a new list | Single consumption. chain(*large_list) unpacks eagerly — use chain.from_iterable instead |
| itertools.chain.from_iterable | Yes | O(1) | Values from each sub-iterable in sequence | Lazy merging of resource-bound iterables (files, cursors) | The outer iterable must itself be iterable — generator preferred to avoid upfront evaluation |
| itertools.product | Yes | O(r) per tuple — r is the repeat/length | Tuples — Cartesian product | Config grid generation, test matrix, hyperparameter search | Output count is n^r — measure with math.prod before materialising |
| itertools.permutations | Yes | O(r) per tuple | Tuples — ordered arrangements | Ordering problems, scheduling, path enumeration | n!/(n-r)! output count. permutations(12,12) = 479M tuples at 152 bytes each = ~72 GB |
| itertools.combinations | Yes | O(r) per tuple | Tuples — unordered selections | Feature selection, subset enumeration | n!/(r!(n-r)!) output count — grows fast. Always check math.comb first |
| itertools.groupby | Yes | O(1) | Key + sub-iterator pairs | Grouping pre-sorted streams — NOT unsorted data | Groups consecutive identical keys only — must sort by key first or get duplicate groups |
| itertools.tee | Yes | O(delta) — delta between consumer positions | n independent iterators over the same source | Lockstep consumers (e.g., zip over two clones of a stream) | Buffer grows with consumer divergence — materialise to list if branches advance unevenly |
| itertools.accumulate | Yes | O(1) | Running aggregate values | Running totals, rolling max/min, running product | operator.mul produces factorially growing integers — CPU cost grows, no overflow |
| itertools.pairwise | Yes — Python 3.10+ | O(1) | Overlapping consecutive pairs (a,b), (b,c)... | Time-series deltas, state transition detection, consecutive comparisons | Requires Python 3.10+. For earlier versions use tee-based compat shim |
| itertools.batched | Yes — Python 3.12+ | O(n) per batch tuple | Tuples of at most n items — final batch may be shorter | Chunked API writes, batch DB inserts, windowed processing | Requires Python 3.12+. Final chunk is shorter than n if input does not divide evenly |
| itertools.compress | Yes | O(1) | Elements where boolean mask is truthy | Applying a pre-computed filter mask at C speed | Mask must be an iterable of the same length — both exhausted together |
| itertools.islice | Yes | O(1) | Bounded slice of source iterator | Lazy windowing and sampling — especially on generator sources | No negative indices. On pre-built lists, direct slicing is faster |
| itertools.zip_longest | Yes | O(1) | Tuples — fills missing values with fillvalue | Merging streams of unequal length without silent truncation | Standard zip truncates silently — use zip_longest when stream lengths may differ |
| File | Command / Code | Purpose |
|---|---|---|
| io | eager_numbers = list(range(1_000_000)) | How itertools Actually Works |
| io | def generate_ids(prefix: str, count: int): | Infinite Iterators, Slicing and Chaining |
| io | PYTHON_VERSION = sys.version_info | pairwise and batched |
| io | n, r = 12, 12 | Combinatorics: product, permutations, combinations |
| io | records = [f"row-{i}" for i in range(500_000)] | Performance |
| io | data = [ | groupby, tee, and accumulate |
| MemoryCheck.py | def process_with_list(data_stream): | What Is Itertools and Why Should You Use It? |
| ChunkProcessor.py | def grouper(iterable, chunk_size, fill_value=None): | The grouper Recipe |
| DealHands.py | suits = ['S', 'H', 'D', 'C'] | Dealing a Deck of Cards Without State |
| RelayTeams.py | swimmers = [ | Building Relay Teams From Swimmer Data |
| FibonacciStream.py | from itertools import accumulate, islice | Recurrence Relations |
| SP500Rolling.py | from itertools import islice, accumulate, tee | Analyzing the S&P500 |
Key takeaways
list() call returns empty with no error. Use a factory function for replay or materialise once when memory allows.list() for divergent ones.list() call on combinatoric output.Common mistakes to avoid
6 patternsCalling groupby on unsorted data
Consuming an itertools object twice
Using tee when consumers advance at wildly different rates
Calling list() on combinatoric iterators without checking size first
list() returns.filter() and islice() to process lazily — discarded items never enter memory.Using chain(*large_iterable) instead of chain.from_iterable when sub-iterables are resource-bound
Using zip instead of zip_longest when merging streams that may have different lengths
Interview Questions on This Topic
What is the difference between itertools.chain(a, b) and itertools.chain.from_iterable([a, b]), and when does the distinction matter in production?
permutations(range(12)) — how many results does it produce and how much memory would list() require on 64-bit CPython? How would you safely process this?
list() on it. Safe processing: apply filter() directly on the lazy iterator to discard invalid permutations before they are ever stored, use islice to take a bounded sample, or use a for loop with an early break. Always check math.perm before writing any code that touches this iterator.A teammate writes itertools.groupby(records, key=lambda r: r.department) and complains the same department appears multiple times. What is the bug?
sorted() call immediately before groupby using the same key function: groupby(sorted(records, key=lambda r: r.department), key=lambda r: r.department). The groupby call itself does not need to change — sorting the input is the entire fix. This is the most common itertools bug in production and the one most likely to go undetected because the output looks plausible.What are pairwise and batched, when were they added, and what did people use before them?
Explain how tee works internally and describe a scenario where it causes a memory problem.
You have a production sensor pipeline that processes 50 million readings per day. A colleague suggests replacing the filter logic with itertools.compress. Is that a good idea and why?
filter() with a Python lambda executes Python-level code for every element. At 50 million elements per day the per-element overhead compounds. The more important production win is often memory: a list comprehension with filter creates intermediate structures that the garbage collector must collect. compress with a pre-computed mask produces output without intermediate allocation, reducing GC pause frequency. The pre-computed mask itself must fit in memory — if the mask is large, consider computing it as a generator and passing that to compress rather than materialising it. Benchmark on your actual data with timeit and memory_profiler before committing, but compress is the right starting point for this pattern.Frequently Asked Questions
chain(*list_of_lists) unpacks the outer list at call time using Python's argument unpacking. The outer list must exist in memory at that point. The inner lists are not materialised by chain itself — chain holds references to them. But if list_of_lists is large, the unpacking itself is O(k) where k is the number of inner lists. Use chain.from_iterable(list_of_lists) to avoid the unpacking overhead entirely — it accesses each inner list one at a time as it exhausts the previous one.
Yes, with caveats. Iterating over a DataFrame with a for loop yields column names, not rows — that is a common surprise. To iterate over rows use df.itertuples() which yields namedtuples, or df.iterrows() which yields (index, Series) pairs. Both are significantly slower than vectorised pandas operations. For most use cases, pandas' own groupby, rolling, and apply methods are faster than itertools pipelines over row iterators. itertools is most useful with pandas for combining multiple DataFrames lazily with chain.from_iterable when you cannot load all of them into memory simultaneously.
Check in this order: (1) Is the source iterable empty? Add print(list(source)) before building the pipeline. (2) Is the pipeline being consumed twice? Find every list(), sum(), for loop, or any() on the same iterator variable. (3) If using groupby, is the input sorted by the grouping key? (4) If using filterfalse or compress, is the predicate or mask correct — try inverting it. (5) Is the pipeline infinite? Add an islice(pipeline, 100) to force a finite bound and inspect the first 100 items. Add a for item in pipeline: print(item) at each stage to narrow down where values disappear.
Not directly — the object is lazy and may be infinite. You can count by consuming: count = sum(1 for _ in iterator), but that exhausts the iterator. If you need both the count and the data, either materialise to a list (if it fits) or use tee to duplicate the stream and consume one branch for counting and one for processing. For combinatoric iterators specifically, use math.perm or math.comb — they compute the exact count in O(1) without touching the iterator at all.
takewhile stops as soon as the predicate returns False — it does not inspect the rest of the iterable. filter continues through the entire iterable returning every element where the predicate is True. Use takewhile when your data has a structural boundary — sorted timestamps, a sequence that transitions from valid to invalid — and you know nothing useful comes after the first failure. Use filter when matching elements may appear anywhere in the stream. Using takewhile on unsorted data is a common mistake: the first out-of-order element stops the entire iteration even if many matching elements remain.
Not directly. itertools objects implement the synchronous iterator protocol (__iter__ and __next__). They cannot be used with async for, which requires __aiter__ and __anext__. In async codebases, the standard library offers no async itertools equivalent. Third-party libraries like aiostream provide async versions of chain, map, filter, and similar tools. For simple cases you can wrap a synchronous itertools pipeline in asyncio.to_thread to run it off the event loop, but this blocks a thread for the duration. For high-throughput async pipelines, native async iterators or aiostream are the right tools.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Python Libraries. Mark it forged?
14 min read · try the examples if you haven't