The itertools module is Python's pre-built kit of lazy iterator operators. The functions read like a vocabulary of stream operations: chain a few sources together, filter on a predicate, take the first ten, group consecutive duplicates, enumerate every product variant. Most of them are tiny on their own, but they compose into pipelines that would otherwise need bespoke generator functions. This lesson walks through every commonly-used tool, grouped by what it does, with one e-commerce example and a "use when" note for each.
Generator expressions let you transform and filter streams lazily. yield from lets you flatten one generator into another by hand. itertools is the library that names the common shapes of those pipelines so you don't have to rewrite them every time. Every function in this module returns an iterator, holds no more than a handful of items in memory, and works on any iterable, lists, tuples, file objects, generator expressions, or other itertools chains.
The mental model worth holding: each itertools function is a small generator that knows one trick. You wire them together with the chain operator (function call composition) the same way you'd build a Unix pipeline. The trick is knowing which one to reach for.
chain glues two product lists into one stream without building a third list, and islice peels off the first four items. Neither function builds the full combined list in memory. That's the pattern we'll see again and again.
This first group of tools rearranges or filters items from one or more input streams. None of them produce new items out of thin air; they just rewire what's coming in.
chain(*iterables) and chain.from_iterable(iterable_of_iterables)chain concatenates iterables. It yields every item from the first iterable, then every item from the second, and so on. chain.from_iterable does the same thing when the iterables are themselves wrapped in another iterable (often a generator that produces lists).
chain.from_iterable is the version you reach for when the inputs are produced lazily, for example by a generator that yields one warehouse's inventory at a time.
Use when: you have several iterables and want to walk them as one stream without copying everything into a new list. It's the iterator equivalent of list_a + list_b + list_c, but without the allocation. yield from inside a generator function does the same flattening; chain is the non-generator alternative when you don't need to write your own generator.
compress(data, selectors)compress walks two iterables side by side. For each pair, it yields the item from data only if the matching selector is truthy. It stops as soon as either iterable runs out.
The selector doesn't have to be exactly 0 or 1. Any truthy value (non-zero number, non-empty string, True) keeps the item; any falsy value (0, "", None, False) drops it. This is handy when the in-stock flag comes from a separate sensor or database column.
Use when: you've already computed a parallel list of booleans somewhere else (a database query, a separate calculation), and you want to filter without re-running the predicate.
dropwhile(pred, iterable)dropwhile skips items while the predicate returns true, then yields every remaining item, including the first one that flipped the predicate to false. Once it starts yielding, it never looks at the predicate again.
Notice the 0.0 at index 4 stayed in the output. dropwhile only skips a leading run; once the predicate fails, the rest passes through untouched.
Use when: the input has a leading prefix you want to discard (warm-up data, header rows, free trial entries before the first real purchase), and the rest of the stream is "the real data."
takewhile(pred, iterable)takewhile is the mirror image. It yields items while the predicate is true and stops the moment the predicate fails, even if more matching items appear later.
The 49.99 at the end never appears because takewhile had already stopped at the 0.0. That's the key difference from filter, which would have kept both 49.99 and dropped only the zero.
Use when: you want to consume a sorted or otherwise structured stream up to a boundary. For example, reading reviews until the first one below 4 stars on a list already sorted by rating.
filterfalse(pred, iterable)filterfalse is the inverse of the built-in filter. It yields items where the predicate is false. It's there for cases where the negative phrasing reads more naturally than wrapping a lambda in not.
You could write the same thing as filter(lambda p: p != 0.0, cart_prices). filterfalse exists so you can keep the predicate phrased positively when that reads better, especially with named helper functions like is_out_of_stock or is_refunded.
Use when: the predicate has a clear positive name and you want the negative half of the split. Pair it with filter over the same iterable when you need both halves (but see the note on tee below before doing that).
islice(iterable, [start,] stop[, step])islice is the iterator-friendly version of regular slicing. Plain slicing (some_iter[2:10]) doesn't work on generators or other lazy iterators because they don't support indexing. islice walks the iterator forward, skipping and yielding the right items, without ever building the full list.
The generator could be billions of items long; islice only reads as far as stop and then stops. The signature mirrors Python's built-in slice, so islice(it, 5) means "first five," islice(it, 5, 10) means "items 5 through 9," and islice(it, 0, None, 2) means "every other item from the start."
Cost: islice is O(stop) because it has to read and discard everything up to start. There's no way around this for a one-pass iterator. If you need true random access, materialize to a list first (and pay the memory cost).
Use when: you want to limit, page, or step through a lazy stream. Common patterns: islice(stream, 10) to peek at the first ten, islice(stream, 1, None) to skip a header row.
starmap(func, iterable_of_tuples)starmap is like the built-in map, but it expects each item in the iterable to be a tuple of arguments, and it unpacks that tuple when calling the function. It pairs naturally with zip, which produces tuples.
Without starmap, you'd write map(lambda pair: line_total(*pair), cart). starmap does that unpacking for you.
Combined with zip, it lets you apply a multi-argument function across parallel iterables:
Use when: you have a function that takes multiple arguments and a sequence of argument tuples, often the output of zip. It's cleaner than a generator expression that unpacks by hand.
The next set of tools builds derived streams from a single input: running totals, paired neighbours, grouped runs of equal items, fan-out copies. They're where itertools starts to feel like a tiny query engine.
groupby(iterable, key=...)groupby yields (key, group_iterator) pairs as it walks the input. Each pair represents a run of consecutive items that share the same key. The group iterator is itself lazy; iterate it to get the items in that group, or wrap it in list to materialize them.
The critical thing to understand, and the source of the most common groupby bug, is that it only groups consecutive items. If the input isn't already sorted by the same key, you'll get many small groups instead of a few large ones.
Here's the right way: sort first, then group.
And here's what happens when you forget the sort:
Five separate groups, one per order, because no two adjacent orders share a category. The function did exactly what it was documented to do; it groups runs. If you want SQL-style GROUP BY, sort first or use collections.defaultdict to bucket by key.
Cost: groupby itself is O(n), one pass. The sort that you almost always need to do first is O(n log n). For really large datasets where sorting is expensive, a defaultdict(list) approach is often the better fit.
Use when: the input is already sorted by your key (a query result with ORDER BY, a log file in time order, a list you just sorted), and you want to walk the runs in one pass.
accumulate(iterable, func=operator.add, *, initial=None)accumulate produces a running tally. By default it's a running sum, but you can pass any two-argument function. Use operator.max and operator.min for running extremes, operator.mul for running products.
The first item is always the first input as-is. Each subsequent item is func(previous_result, next_input). So the third entry above is (12.99 + 7.49) + 1.99 = 22.47.
Running maximum is a common variant. Pass func=max:
Each entry is the highest revenue seen so far. This is the same shape you'd want for "best price seen" or "highest cart total this session."
The initial keyword argument (Python 3.8+) lets you seed the accumulator. The seed becomes the first item of the output, and the rest are computed from there.
The seed is the starting balance; each new charge adds to it.
Use when: you need a cumulative version of a stream: running totals on a cart, running averages, prefix sums for analytics, monotonically growing high-water marks.
tee(iterable, n=2)tee splits one iterator into n independent iterators. Each copy can be advanced separately, and they all see the same items in the same order. Internally, tee buffers items between the slowest and fastest consumer, so all the copies eventually see everything.
Each branch consumes the input independently. That's useful when you need two passes over the same lazy source, for example computing the average and then comparing each item to that average.
The buffering is where you have to be careful. If one consumer races far ahead of the other, tee has to hold every item the slow consumer hasn't yet seen.
Cost: tee buffers items between consumers. If consumer A reads 1,000,000 items and consumer B hasn't read any yet, tee is holding all 1,000,000 in memory. For divergent consumption patterns, prefer materializing the source to a list once and iterating that list multiple times.
This works, but every item from stream() is now in tee's internal buffer because a was fully drained before b started. For a small stream that's fine. For a multi-gigabyte log, it isn't.
A practical rule: if both consumers will walk the stream at roughly the same rate, tee is a clean fit. If one will lag far behind, just call list(source) once and iterate the list.
Use when: you need two or more parallel lazy passes over the same iterator and the consumers will stay close in position.
pairwise(iterable) (Python 3.10+)pairwise yields successive overlapping pairs from the input. For input [a, b, c, d], it produces (a, b), (b, c), (c, d). It's a small function, but writing it correctly by hand (with zip(it, it[1:]) or manual buffering) is one of those things people get wrong often enough that the standard library now ships it.
Each gap is the time between one order and the next. The number of pairs is always one less than the number of items, which matches the shape of "differences between neighbours."
You can chain it into other tools. For example, find the largest quiet period between orders:
Use when: you need to compare or compute over adjacent elements: time gaps, price changes day-over-day, transitions between consecutive order statuses. The version note matters; on Python 3.9 and earlier, write the helper yourself or upgrade.
batched(iterable, n) (Python 3.12+)batched chops an iterable into successive n-sized tuples. The final batch may be shorter if the input doesn't divide evenly. As of Python 3.13, it also accepts a strict=True keyword that raises if the last batch is short, which is handy when you genuinely need every batch to be full.
This is the right tool for paginated display ("show three items per page"), bulk operations ("send 50 orders to the warehouse API at a time"), or any other case where you want fixed-size chunks of a stream without writing the off-by-one logic yourself.
Use when: you need fixed-size groups and you're on Python 3.12 or newer. On earlier versions, you can roll your own:
The standard library version is faster and clearer; use it when you can.
This last set produces new tuples from one or more inputs, following standard combinatorial recipes: Cartesian product, ordered arrangements, unordered selections. They're invaluable for generating product variants, test case matrices, or bundle suggestions.
These functions all return iterators, but the output sizes grow combinatorially with the input. A naive list(combinations(range(50), 25)) produces over 126 trillion tuples and will not finish on any normal machine. Wrap them in islice when you only need a sample, and be deliberate about what you materialize.
product(*iterables, repeat=1)product yields the Cartesian product of the input iterables: every combination of one item from the first input, one item from the second, and so on. The total size is len(a) * len(b) * len(c) * ....
The order is rightmost-fastest, like nested for loops with the rightmost input as the inner loop. That matches the equivalent explicit form:
The repeat keyword multiplies the same iterable against itself. product([0, 1], repeat=3) yields every 3-bit pattern from (0, 0, 0) to (1, 1, 1).
Cost: product(a, b, c) produces |a| * |b| * |c| tuples. Three lists of size 100 produce a million; four produce a hundred million. Always estimate the size before materializing. Reach for islice if you only need a preview.
Use when: enumerating every combination of independent choices: shirt variants (size x color x fit), test inputs (browser x OS x screen size), or feature flag combinations.
permutations(iterable, r=None)permutations yields all r-length ordered arrangements of the input. If r is omitted, it defaults to the length of the input, giving every full reordering. Items are treated as unique by position, not value.
Six arrangements, matching 3! = 6. For a 3-product banner where the slot order matters, these are the layouts you could choose between.
permutations(iterable, r) with r smaller than the input length picks r items and arranges them. The number of results is n! / (n - r)!.
Cost: permutations(n) produces n! results. 10 items already gives 3.6 million; 15 gives over a trillion. Never call list(permutations(...)) on more than about 8 items without thinking carefully.
Use when: order matters and every input is distinct. Display ranking, route order between warehouses, problem instances for brute-force search.
combinations(iterable, r)combinations yields all r-length unordered selections of the input. Each selection is reported in the order the items appear in the input, and no item is repeated within a single selection. The number of results is C(n, r) = n! / (r! * (n - r)!).
Six bundles, matching C(4, 2). These are the unique two-product bundles you could offer, with ("mug", "notebook") and ("notebook", "mug") treated as the same bundle.
Use when: order doesn't matter and items can't repeat. Bundle suggestions, tournament pairings, choosing which subset of features to A/B test.
combinations_with_replacement(iterable, r)Same as combinations, but allows the same input item to be chosen more than once in a single selection. The number of results is C(n + r - 1, r).
Six pairs, including the three "double scoop of the same flavor" entries that combinations would skip.
Cost: Like permutations and product, this grows fast. combinations_with_replacement(n, r) is C(n + r - 1, r). Pair it with islice if you need a sample of the early results rather than the full enumeration.
Use when: you need unordered selections where repeats are legal. Multi-scoop ice cream, multiset enumeration, distributing identical units across categories.
The reason itertools matters is that the functions compose. Each one returns an iterator, and each one accepts an iterator, so you can stack them end to end. The result still walks the source one item at a time, with no intermediate list ever built.
Here's a small pipeline that takes two warehouses' inventories, drops the items that are out of stock, picks the first three remaining, and computes a display string per item:
Three rows, computed from six possible items, with no intermediate list larger than one tuple in memory at a time. Here's the same pipeline as a Mermaid diagram so the flow is visible:
Read the diagram left to right. Each stage pulls items on demand from the stage to its left. When islice has produced its third item and we leave the loop, every upstream stage stops too: compress never asks chain for a fourth item, and chain never asks warehouse_b for its last product. The pipeline is lazy from start to finish.
This is the property that makes itertools worth learning. Each function on its own is forgettable. The combination, applied to a stream that could be infinite or just very large, is a different way of thinking about data flow than the build-a-list-then-filter-it style.
itertools also includes three iterators that never end on their own: count, cycle, and repeat. They produce an unbounded stream and are designed to be paired with something like islice, takewhile, or zip that imposes a stopping condition from outside.
For now, just know they exist. If a problem feels like "I want to assign IDs starting at 1," or "I want to alternate between A and B forever and stop when the input runs out," those three are the right starting point.
10 quizzes