Introduction
In the previous lesson, we saw how extended unpacking in for loops (for name, *scores in data:) provides a declarative and efficient way to handle structured data without manual indexing. This was our first major step into building expressive, lazy data processing pipelines.
Today, we dive into the itertools module, Python's standard library powerhouse for advanced iteration. You'll learn that writing efficient Python isn't just about choosing the right algorithm; it's about using the right tools to manipulate streams of data without loading everything into memory. This lesson specifically addresses how to combine, slice, and duplicate iterators, replacing common but inefficient list-based operations. We will cover three fundamental functions: itertools.chain, itertools.islice, and itertools.tee.
The itertools Philosophy: Lazy and Memory-Efficient
Coming from a background where collections are often manipulated by creating new, fully-formed collections, the itertools approach represents a significant mental shift. The core principle is laziness: itertools functions take iterators as input and produce iterators as output. They compute items one by one, on demand, rather than creating intermediate lists in memory.
This video from mCoding provides a superb introduction to this philosophy.
Python itertools - The key to mastering iteration
To understand the 'why' behind itertools, watch this brief introduction from the video 'Python itertools - The key to mastering iteration'. It explains the concept of lazy evaluation and introduces itertools as a set of fundamental building blocks.
Watch the first two minutes (00:00 - 01:54). Focus on how lazy objects avoid wasting memory and allow you to work with potentially infinite sequences.
Now, let's see this principle in action with our three target functions.
1. Combining Iterators with itertools.chain
A common task is to process items from several sequences as if they were one.
The Anti-Pattern: List Concatenation
If you have two lists, the + operator is convenient.
# Bad: Creates a new, potentially large list in memory
low_priority = ['task5', 'task6']
high_priority = ['task1', 'task2']
all_tasks_list = high_priority + low_priority
# This creates a third list: ['task1', 'task2', 'task5', 'task6']
This is a classic "eager" operation. It allocates memory for a brand new list containing copies of all elements from the originals. For small lists, it's fine. For large lists or generators, it's a memory disaster.
The Pythonic Solution: itertools.chain
itertools.chain treats multiple iterables as a single, logical sequence without creating an intermediate list.
from itertools import chain
# Good: Creates a lazy iterator. No new list is formed.
low_priority = ['task5', 'task6']
high_priority = ['task1', 'task2']
all_tasks_iterator = chain(high_priority, low_priority)
for task in all_tasks_iterator:
print(task) # Processes 'task1', 'task2', 'task5', 'task6' sequentially
The chain object doesn't store the items. It simply holds references to the original iterables and pulls from them in order until they are exhausted. The memory vs. time trade-off is stark and critical for performance.

The following resources provide detailed examples of chain.
Python Tutorial: Itertools Module - Iterator Functions for Efficient Looping
Corey Schafer's video 'Itertools Module - Iterator Functions for Efficient Looping' demonstrates chain as a memory-efficient alternative to list concatenation.
Watch from 21:30 to 23:38. He clearly explains why list1 + list2 can be inefficient and how chain solves that problem.
itertools also provides chain.from_iterable() for chaining a sequence of iterables, for instance, list_of_lists = [[1, 2], [3, 4]]. chain.from_iterable(list_of_lists) would produce 1, 2, 3, 4.
2. Slicing Iterators with itertools.islice
You know how to slice lists (my_list[5:10]), but this creates a copy. More importantly, you can't slice a generator or other general-purpose iterator this way. Trying to do list(my_generator)[:10] first materializes the entire generator into a list, defeating its memory-saving purpose.
The Pythonic Solution: itertools.islice
itertools.islice returns an iterator that yields a selected slice of items from another iterator. It does this without loading unsliced portions into memory. Its arguments are (iterable, stop) or (iterable, start, stop[, step]).
This is extremely useful for tasks like processing a header or the first N lines of a huge file.
Example: Processing a large log file
from itertools import islice
# Let's pretend `open(...)` returns a generator of millions of lines
def get_log_lines(filename="huge_file.log"):
with open(filename, 'r') as f:
# In a real scenario, we'd read from f. For this example, we simulate.
for i in range(1_000_000):
yield f"Line {i}: some data...\n"
# Anti-pattern: Loads the WHOLE file into memory first!
# all_lines = list(get_log_lines())
# header = all_lines[:3]
# Good: Reads only the first 3 lines from the file iterator
log_iterator = get_log_lines()
header = islice(log_iterator, 3)
print("--- Header ---")
for line in header:
print(line, end='')
# The rest of the log_iterator is still available for processing
# and has not been loaded into memory yet.
The video from mCoding gives a concise look at islice.
Python itertools - The key to mastering iteration
This clip from the mCoding video demonstrates islice as a lazy equivalent to list slicing.
Watch from 10:09 to 11:00. Note the warning: islice is more general but can be slower than native list slicing if you already have a list. The power of islice is for general iterators.
3. Duplicating Iterators with itertools.tee
An iterator is a one-time-use object. Once you loop over it, it's exhausted. What if you need to process the same stream of data in two different ways?
The Anti-Pattern: Materializing to a List
The naive approach is to convert the iterator to a list first.
# my_generator produces millions of items
data_list = list(my_generator) # Memory-heavy operation!
# Now you can iterate over data_list as many times as you want
process1(data_list)
process2(data_list)
This works, but again, it throws away the memory benefits of using an iterator in the first place.
The Pythonic Solution: itertools.tee
itertools.tee splits a single iterator into a specified number of independent iterators. Each new iterator gives you the same items as the original.

from itertools import tee
data_stream = (x*x for x in range(10)) # A generator expression
# Create two independent iterators from the single stream
stream1, stream2 = tee(data_stream, 2)
print("First stream (all items):", list(stream1))
# Output: First stream (all items): [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print("Second stream (all items):", list(stream2))
# Output: Second stream (all items): [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
A Critical Caveat:tee is not magic. If one of the new iterators is consumed much faster than the others, tee must internally buffer the items that the lagging iterators haven't seen yet. If one iterator is fully consumed while the other is untouched, tee will have stored all the items in memory, effectively behaving like list().
Therefore, tee is most effective when the created iterators are consumed more or less in parallel.
Rule: Once you use
teeon an iterator, you should no longer use the original iterator. Only use the new ones created bytee.
Putting It All Together: A Practical Refactor
Now, let's use all three functions to refactor a cut function for a deck of cards. The goal is to move the top n cards to the bottom of the deck.
The article 'Python itertools By Example' from Real Python provides an excellent, practical example of refactoring a function from using memory-inefficient list operations to a lazy itertools-based approach. We will focus on the 'Dealing a Deck of Cards' section.
Read the section 'Dealing a Deck of Cards'. First, examine the initial cut() function that uses list slicing (deck[n:] + deck[:n]). Then, study the refactored version that uses itertools.tee, itertools.islice, and itertools.chain. This side-by-side comparison perfectly illustrates the shift from an eager, memory-heavy style to a lazy, efficient one.
As you saw in the article, the refactored function is a beautiful piece of itertools composition:
deck1, deck2 = tee(deck, 2): Duplicates the deck iterator so we can "look" at the top and bottom parts independently.top = islice(deck1, n): Creates an iterator for the firstncards.bottom = islice(deck2, n, None): Creates an iterator for the rest of the cards (fromnto the end).return chain(bottom, top): Creates a final iterator that first yields from the bottom part, then from the top part, without ever creating a full new list of cards.
Exercise: Processing Sensor Data Streams
You are given two iterators representing data streams from two different temperature sensors. sensor1_data provides readings from a primary sensor, and sensor2_data provides backup readings. Your task is to write a function process_streams that does the following:
- Combines the two streams into one, with
sensor1_datacoming first. - Skips the first 5 readings of the combined stream, as they are part of a calibration phase.
- Duplicates the remaining, calibrated stream into two independent streams:
- One for calculating the average temperature.
- One for detecting any readings above a
threshold.
- The function should return a tuple containing the calculated average and a list of the high-temperature readings.
Use itertools.chain, itertools.islice, and itertools.tee to implement this efficiently.
from itertools import chain, islice, tee
from statistics import mean
# --- Do not modify this setup ---
sensor1_data = iter([20.1, 20.3, 20.4, 20.2, 20.5])
sensor2_data = iter([21.0, 21.2, 25.5, 21.3, 25.8, 21.1])
# ---
def process_streams(stream1, stream2, threshold):
"""
Combines, slices, and processes two data streams efficiently.
Args:
stream1: The first iterator of sensor data.
stream2: The second iterator of sensor data.
threshold: The temperature threshold for high readings.
Returns:
A tuple containing (average_temp, high_readings_list).
"""
# 1. Combine the two streams into a single logical stream.
combined_stream = chain(stream1, stream2)
# 2. Skip the first 5 calibration readings.
calibrated_stream = islice(combined_stream, 5, None)
# 3. Duplicate the calibrated stream for two separate analyses.
# 'avg_stream' for calculating the mean.
# 'high_temp_stream' for finding values above the threshold.
avg_stream, high_temp_stream = tee(calibrated_stream, 2)
# 4. Perform the two analyses.
# Note: list() and mean() will consume the iterators.
all_readings = list(avg_stream)
average_temp = mean(all_readings) if all_readings else 0
high_readings = [temp for temp in high_temp_stream if temp > threshold]
return (average_temp, high_readings)
# --- Testing your solution ---
avg, highs = process_streams(sensor1_data, sensor2_data, 25.0)
print(f"Calculated Average (post-calibration): {avg:.2f}")
print(f"High Temperature Readings: {highs}")
# --- Verification ---
# Expected combined stream: 20.1, 20.3, 20.4, 20.2, 20.5, 21.0, 21.2, 25.5, 21.3, 25.8, 21.1
# After skipping 5: 21.0, 21.2, 25.5, 21.3, 25.8, 21.1
# Expected average: mean([21.0, 21.2, 25.5, 21.3, 25.8, 21.1]) -> 22.65
# Expected highs: [25.5, 25.8]
assert abs(avg - 22.65) < 0.01
assert highs == [25.5, 25.8]
print("\n✅ Your solution is correct!")
Click to see the solution
from itertools import chain, islice, tee
from statistics import mean
def process_streams(stream1, stream2, threshold):
"""
Combines, slices, and processes two data streams efficiently.
Args:
stream1: The first iterator of sensor data.
stream2: The second iterator of sensor data.
threshold: The temperature threshold for high readings.
Returns:
A tuple containing (average_temp, high_readings_list).
"""
# 1. Combine the two streams into a single logical stream.
combined_stream = chain(stream1, stream2)
# 2. Skip the first 5 calibration readings.
# 'islice(iterable, 5, None)' creates an iterator that starts after the 5th item.
calibrated_stream = islice(combined_stream, 5, None)
# 3. Duplicate the calibrated stream for two separate analyses.
avg_stream, high_temp_stream = tee(calibrated_stream, 2)
# 4. Perform the two analyses.
# list() consumes the avg_stream to calculate the mean.
# The list comprehension consumes the high_temp_stream.
# Because they are teed, they can be consumed independently.
all_calibrated_readings = list(avg_stream)
average_temp = mean(all_calibrated_readings) if all_calibrated_readings else 0
high_readings = [temp for temp in high_temp_stream if temp > threshold]
return (average_temp, high_readings)
# --- Test Execution ---
sensor1_data = iter([20.1, 20.3, 20.4, 20.2, 20.5])
sensor2_data = iter([21.0, 21.2, 25.5, 21.3, 25.8, 21.1])
avg, highs = process_streams(sensor1_data, sensor2_data, 25.0)
print(f"Calculated Average (post-calibration): {avg:.2f}")
print(f"High Temperature Readings: {highs}")
Conclusion
You have now added three of the most fundamental itertools functions to your arsenal. By internalizing these patterns, you can build complex data processing pipelines that are both highly readable and memory-efficient—a cornerstone of professional Python code.
Key Takeaways:
itertools.chain: Use to combine iterables without creating a new list. It's the lazy alternative tolist1 + list2.itertools.islice: Use to slice any iterator without materializing it into a list. It's the lazy, general-purpose alternative tomy_list[start:stop].itertools.tee: Use to create multiple, independent iterators from a single source. It's the lazy alternative tolist(my_generator). Remember the performance caveat: it's best when iterators are consumed in parallel.
In our next lesson, we will continue exploring itertools by moving from combining and slicing to aggregation and analysis. You will learn to use itertools.groupby to group consecutive items and itertools.accumulate to compute running totals, further enhancing your ability to process data streams efficiently.