Skip to content
← All articles

tee and product: the two itertools functions that will OOM you

Both are marketed as lazy and both hold everything. tee is the worse trap, because draining one side makes its buffer strictly larger than the list() you were avoiding.

itertools is the lazy module, so every function in it is lazy. Two of them are not, in the way that matters.

tee: the buffer is the whole stream

tee(iterable, n) gives you n independent iterators over one source. It does that by keeping an internal buffer of every item that some consumer has seen and another has not yet reached.

If you interleave the consumers, the buffer stays small — that is the intended use, and it is genuinely useful for “look one item ahead while still processing this one”.

If you drain one side first:

a, b = tee(source)
everything = list(a)      # b's buffer now holds the entire stream
for item in b:            # reads from a full in-memory buffer
    ...

you have materialised the whole stream plus paid for the tee machinery. That is strictly worse than data = list(source) and iterating data twice — same memory, more indirection, more surprise. The docs say this outright, and it is still the most common misuse of the function.

There is a second rule that catches people: after teeing, do not use the original iterator. The tees assume they own it. Advancing the source directly makes items disappear from some or all of the tees.

💡a, b, c = tee(source, 2) — what does mypy say, and what happens click to reveal

at runtime? mypy says nothing. tee is typed tuple[Iterator[T], ...] — a variadic tuple, because the arity depends on the runtime value of n, which a type cannot express. Unpacking a variadic tuple into any number of names type-checks.

At runtime it raises ValueError: not enough values to unpack (expected 3, got 2).

This is a signature that cannot state its own contract, and it is worth recognising the shape: whenever the number of results depends on an argument value, the return type degrades to variadic and unpacking stops being checked. tee and batched are both instances. A checker cannot save you; reading the signature can.

product: eager inputs, exponential output

product is lazy in its output and eager in its inputs. It has to be: to produce the second element of the first tuple it needs to have seen all of the second iterable, so it converts every argument to a tuple up front, before yielding anything.

product(giant_generator, [1, 2])   # consumes giant_generator entirely, immediately

And the output grows multiplicatively. product(range(100), repeat=4) is $100^4 = 10^8$ tuples. Nothing warns you; the expression is four tokens long and the loop simply never finishes.

The repeat= argument is the sharp edge, because it is the one place where a small edit to a constant changes the running time by orders of magnitude. repeat=3 to repeat=5 on a 50-element input is 125,000 to 312 million.

Where product earns its place: replacing a nest of for loops whose depth is known and small — parameter sweeps, coordinate grids, truth tables. If you are tempted to write it over an input whose size you do not control, you want a sampler or a search, not an enumeration.

💡You need to check every pair of items in a stream for a conflict. click to reveal

product(stream, stream) is wrong for two separate reasons. What are they, and what do you write? First, stream is one iterator. product consumes it into a tuple for its first argument, at which point the second argument is an exhausted iterator and the product is empty. You get no pairs and no error.

Second, even with a list, product(items, items) gives $n^2$ ordered pairs including every (x, x) and both (a, b) and (b, a). For a symmetric conflict check that is twice the work plus $n$ meaningless self-comparisons.

What you want is itertools.combinations(items, 2): $\binom{n}{2}$ unordered pairs, no self-pairs, and it takes a sequence so the “one iterator, two positions” problem cannot arise. It is still quadratic — if $n$ is large the real answer is to index or bucket the items so you only compare candidates that could conflict.

The general rule

In itertools, laziness is per-function, not per-module. Before using one on a stream you cannot afford to materialise, ask two questions: does it need to see all of its input before producing its first output? and does it retain what it has already yielded? chain, islice, takewhile and pairwise answer no to both. product, permutations and combinations answer yes to the first. tee and cycle answer yes to the second.