Skip to Content
Course content

178: The itertools Module: tee, zip_longest, starmap

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you start working with large datasets or streaming APIs in Python, you'll quickly realize that converting everything to a list is a recipe for a MemoryError. I've seen plenty of juniors try to "save" a generator by casting it to a list just so they can iterate over it twice. That works fine for a few hundred items, but when you're dealing with a million-row log file, you're essentially defeating the purpose of using iterators in the first place.

The Memory Trap of Re-iterating

Imagine you have a stream of sensor readings and you want to calculate the difference between each reading and the one that follows it. The naive approach is to pull the whole stream into a list, then use a range-based loop to look at index i and i+1. But if that stream is coming from a socket, you can't just "index" into it.

You might try to create two separate copies of the iterator, but since iterators are stateful, once you advance one, the other doesn't magically stay behind—unless you use itertools.tee. Here is how we handle this without blowing up the RAM:

import itertools

def get_readings():
    # Simulating a massive stream of data
    for i in range(1000000):
        yield i * 1.1

readings = get_readings()
# Instead of list(readings), we split the stream
it1, it2 = itertools.tee(readings, 2)

# Advance the second iterator by one position
next(it2, None)

# Now we can zip them to get (current, next) pairs
for current, following in zip(it1, it2):
    # Process the delta...
    pass

The trade-off here is subtle. tee doesn't actually copy the underlying data; it keeps a queue of the elements that one iterator has seen but the other hasn't. If it1 and it2 stay close to each other, memory usage remains low. But if you advance it2 a million steps ahead of it1, tee will store those million elements in memory anyway. It's a tool for small offsets, not for creating independent copies of massive datasets.

The Silent Data Loss of Standard Zip

Next, let's talk about alignment. Often, you'll have two streams of data—say, sensor values and corresponding metadata tags—that you expect to be the same length. The standard zip() is great, but it has a dangerous habit: it stops as soon as the shortest iterable is exhausted. In a production environment, this is a silent failure. You lose data and your logs don't tell you why.

If you need to ensure every single data point is processed, even if the metadata stream runs dry, you want itertools.zip_longest. It lets you specify a fillvalue to use when one side finishes early.

from itertools import zip_longest

readings = [22.1, 22.5, 23.0, 21.8]
tags = ["Normal", "Normal"] # Missing tags for the last two

# Naive zip would just give us two pairs and stop.
# zip_longest keeps going.
for reading, tag in zip_longest(readings, tags, fillvalue="Unknown"):
    print(f"Reading: {reading}, Status: {tag}")

By using zip_longest, you're making an explicit decision about how to handle missing data. It forces you to acknowledge that your data streams might be asymmetrical, which is almost always the case in the real world.

The Unpacking Overhead of Map

Finally, there's starmap. You're likely familiar with map(), which takes a function and a few iterables. But map expects the function to take arguments as individual parameters, while the data is often already bundled into tuples (especially after a zip or zip_longest operation).

The naive way to handle this is to use a lambda to unpack the tuple, like map(lambda x: my_func(x[0], x[1]), zipped_data). It's clunky, it's slightly slower, and it's visually noisy. starmap solves this by automatically unpacking the tuple into the function arguments for you.

from itertools import starmap

def calculate_variance(val, baseline):
    return val - baseline

# Imagine this is the result of our previous zip_longest
data_pairs = [(22.1, 20.0), (22.5, 20.0), (23.0, 20.0)]

# Instead of: map(lambda p: calculate_variance(p[0], p[1]), data_pairs)
variances = starmap(calculate_variance, data_pairs)

print(list(variances))

I prefer starmap because it expresses the intent clearly: "I have a collection of argument tuples, and I want to apply this function to each set." It keeps your logic clean and avoids the overhead of creating a throwaway lambda function for every single call.




📋 Practical Task

Exercise: Telemetry Stream Processor

You are building a tool to analyze a stream of telemetry packets. Each packet consists of a (timestamp, value) tuple. Your goal is to calculate the "rate of change" between consecutive packets and pair that result with a set of "Quality Flags" that might be shorter than the telemetry stream.

Requirements:

  • Start with a generator that yields (timestamp, value) tuples (e.g., (100, 10.5), (101, 11.2), (102, 11.8), (103, 12.5)).
  • Use itertools.tee to create two versions of the generator to calculate the difference in values between consecutive packets.
  • Write a function calc_diff(p1, p2) that takes two packets and returns the difference in their values: p2[1] - p1[1].
  • Use itertools.starmap to apply calc_diff to the zipped pairs of packets.
  • Finally, use itertools.zip_longest to pair these calculated differences with a list of flags (e.g., ["Valid", "Valid"]). If there are more differences than flags, use "No-Flag" as the fill value.

Expected Output Format:
A list of tuples where each item is (difference, flag).

Rating
0 0

There are no comments for now.

to be the first to leave a comment.