Iterators & Generators Deep Dive
Master lazy evaluation, infinite sequences, and memory‑efficient data pipelines with iterators, generators, generator expressions, and itertools — with solved examples and practice problems.
📌 What you'll learn: Iterators let you traverse data without loading everything into memory. Generators produce values on the fly using yield. Together they power Python's lazy evaluation model — essential for handling streams, large files, and infinite sequences. This page covers the full stack: from iter() to itertools and async generators.
🎯 Goal: Write memory‑efficient, elegant Python code using iterators and generators — whether you're processing a billion log lines or building a real‑time data pipeline.
Iterables & Iterators
Custom Iterators
Generator Functions
Generator Expressions
Advanced Methods
itertools
Production
Why Iterators & Generators Matter
Iterators provide a uniform interface for traversing any collection, including infinite or lazily‑computed data. Generators let you write functions that produce a sequence of values over time, pausing and resuming state — ideal for memory‑efficient data processing.
Without iterators, every collection would need its own custom loop syntax. Without generators, you'd have to implement complex iterator classes by hand. Both are fundamental to Python's design — for loops, comprehensions, and even range() rely on them.
In production, generators are used for streaming APIs, reading huge files line‑by‑line, building data pipelines, and implementing coroutines. Libraries like itertools and more-itertools provide battle‑tested building blocks for efficient data processing.
1 Iterables vs Iterators – The Foundation
An iterable is any object that can return an iterator (has __iter__). An iterator is an object with a __next__ method that returns the next item or raises StopIteration. The iter() function gets an iterator from an iterable.
# List is an iterable, not an iterator
nums = [1, 2, 3]
print(hasattr(nums, '__iter__')) # True
print(hasattr(nums, '__next__')) # False
# iter() returns an iterator
it = iter(nums)
print(next(it)) # 1
print(next(it)) # 2
print(next(it)) # 3
# next(it) would raise StopIteration
# For loop does this automatically:
for num in nums: # calls iter(nums), then next() repeatedly
print(num)
StopIteration, they're exhausted. You need a new iterator to traverse again. Iterables, on the other hand, can produce fresh iterators every time.2 Building Custom Iterators
Create a class with __iter__ (returns self) and __next__ (returns next value or raises StopIteration). Useful when you need to maintain complex iteration state.
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current < 0:
raise StopIteration
value = self.current
self.current -= 1
return value
# Usage
for num in Countdown(3):
print(num) # 3, 2, 1, 0
# Separate iterator objects (better pattern):
class CountdownIterable:
def __init__(self, start):
self.start = start
def __iter__(self):
return CountdownIterator(self.start)
class CountdownIterator:
def __init__(self, start):
self.current = start
def __next__(self):
if self.current < 0:
raise StopIteration
val = self.current
self.current -= 1
return val
Countdown class above works but can only be iterated once.3 Generator Functions – yield Magic
A function with yield becomes a generator function. Calling it returns a generator object (an iterator). Each yield pauses the function, saving its state. next() resumes execution until the next yield.
def fibonacci(limit):
"""Generate Fibonacci numbers up to limit."""
a, b = 0, 1
while a <= limit:
yield a
a, b = b, a + b
# Using the generator
for num in fibonacci(50):
print(num, end=' ') # 0 1 1 2 3 5 8 13 21 34
# Or manually:
gen = fibonacci(10)
print(next(gen)) # 0
print(next(gen)) # 1
print(list(gen)) # [1, 2, 3, 5, 8] (rest of values)
# Infinite generator – no StopIteration (careful!)
def infinite_counter(start=0):
while True:
yield start
start += 1
__iter__ and __next__. State (local variables) is saved between yields, making complex state machines trivial to write.4 Generator Expressions – Lazy Comprehensions
Similar to list comprehensions, but use parentheses (...) instead of brackets [...]. They produce values on demand, not all at once. Perfect for large or infinite data.
# List comprehension – creates entire list in memory
squares_list = [x**2 for x in range(10)] # 10 items in memory
# Generator expression – creates generator object
squares_gen = (x**2 for x in range(10)) # lazy, no memory used yet
print(next(squares_gen)) # 0
print(next(squares_gen)) # 1
print(list(squares_gen)) # [4, 9, 16, 25, 36, 49, 64, 81]
# Passing generator expression directly to a function
sum_of_squares = sum(x**2 for x in range(1000)) # no intermediate list
# Filtering with generator expression
even_squares = (x**2 for x in range(100) if x % 2 == 0)
for sq in even_squares:
print(sq) # prints on demand
sum([x for x in range(10**7)]) (consumes ~80 MB) vs sum(x for x in range(10**7)) (uses minimal memory).5 Advanced Generator Methods – send(), throw(), close()
Generators aren't just for producing values. You can send values into a generator, throw exceptions, and close them prematurely. This enables coroutine‑like behavior.
def accumulator():
total = 0
while True:
value = yield total # yields current total, receives next value
if value is None:
break
total += value
acc = accumulator()
print(next(acc)) # 0 (start the generator, yields initial total)
print(acc.send(10)) # 10 (adds 10, yields new total)
print(acc.send(5)) # 15
print(acc.send(3)) # 18
acc.close() # raises GeneratorExit inside generator
# throw() to inject exceptions
def controlled():
try:
while True:
yield "running"
except ValueError:
yield "caught ValueError"
gen = controlled()
print(next(gen)) # "running"
print(gen.throw(ValueError)) # "caught ValueError"
# gen is now exhausted
next(gen) or gen.send(None) before sending non‑None values — the generator must be at a yield expression ready to receive.6 The itertools Module – Iteration Toolbox
itertools provides fast, memory‑efficient building blocks for creating and combining iterators. Master these to write elegant data processing pipelines.
| Function | Description | Example Output |
|---|---|---|
count(10, 2) | Infinite arithmetic progression | 10, 12, 14, 16, ... |
cycle('AB') | Cycle endlessly through iterable | A, B, A, B, A, ... |
repeat('x', 3) | Repeat an object (times optional) | x, x, x |
chain('ab', 'cd') | Concatenate multiple iterables | a, b, c, d |
compress('ABCD', [1,0,1,0]) | Filter elements based on selectors | A, C |
islice('ABCDEF', 2, 5) | Slicing for any iterator | C, D, E |
product('AB', '12') | Cartesian product | ('A',1), ('A',2), ('B',1), ('B',2) |
combinations('AB', 2) | r‑length combinations | ('A','B') |
from itertools import chain, islice, product
# Chain multiple lists lazily
for item in chain([1,2], ['a','b'], (True, False)):
print(item) # 1 2 a b True False
# Slice an infinite generator
from itertools import count
first_ten = islice(count(100, 5), 10) # 100, 105, ..., 145
print(list(first_ten))
# Generate all 2‑letter combinations of 'abc'
for combo in product('abc', repeat=2):
print(combo) # ('a','a'), ('a','b'), ... ('c','c')
itertools.tee() to split a single iterator into multiple independent iterators (but it caches values, so use with care on large streams).7 Production‑Grade Generator Patterns
In production, generators power data pipelines, asynchronous streams, and memory‑safe processing. Here are patterns you'll encounter in real projects.
# 1. Processing pipeline with generators (efficient & composable)
def read_logs(filename):
with open(filename) as f:
for line in f:
yield line.strip()
def filter_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
def extract_timestamps(errors):
for err in errors:
yield err[:19] # first 19 chars are timestamp
# Compose the pipeline
pipeline = extract_timestamps(filter_errors(read_logs("app.log")))
for ts in pipeline:
print(ts)
# 2. Async generator (Python 3.6+) – for async streaming
async def async_counter():
for i in range(5):
await asyncio.sleep(0.1)
yield i
# 3. Generator with resource cleanup (context manager inside)
def read_csv_rows(filename):
with open(filename, newline='') as f:
reader = csv.reader(f)
for row in reader:
yield row
# file automatically closed when generator is exhausted or garbage collected
async def + yield with async for. Perfect for streaming HTTP responses, database cursors, or real‑time sensor data.Solved Practice Problems
🔰 Easy (3 problems)
def counter(start, end):
while start <= end:
yield start
start += 1
for n in counter(1, 5):
print(n) # 1 2 3 4 5squares = (x*x for x in range(6))
print(list(squares)) # [0, 1, 4, 9, 16, 25]person = {"name": "Alice", "age": 30}
for key in person: # dict is iterable (keys by default)
print(key)⚡ Intermediate (3 problems)
def even_only(numbers):
for n in numbers:
if n % 2 == 0:
yield n
evens = even_only([1,2,3,4,5,6])
print(list(evens)) # [2, 4, 6]from itertools import islice
def fib():
a, b = 0, 1
while True:
yield a
a, b = b, a+b
first_10 = list(islice(fib(), 10))
print(first_10) # [0,1,1,2,3,5,8,13,21,34]from itertools import chain
combined = chain([1,2], ['a','b'], (True, False))
print(list(combined)) # [1,2,'a','b',True,False]🚀 Advanced (3 problems)
def running_average():
total, count = 0, 0
avg = None
while True:
x = yield avg
if x is None:
break
total += x
count += 1
avg = total / count
avg_gen = running_average()
next(avg_gen) # prime it
print(avg_gen.send(10)) # 10.0
print(avg_gen.send(20)) # 15.0import csv
def parse_large_csv(filename):
with open(filename, newline='') as f:
reader = csv.reader(f)
header = next(reader)
for row in reader:
yield dict(zip(header, row))
# Memory‑safe: processes one row at a time
for record in parse_large_csv("huge.csv"):
process(record)import asyncio
async def paginated_api(url):
page = 1
while True:
data = await fetch(f"{url}?page={page}")
if not data:
break
for item in data['results']:
yield item
page += 1
# Usage: async for item in paginated_api("..."):
# process(item)Unsolved Practice Problems
🔰 Easy (3 problems)
1. Range‑like Generator
Write a generator function my_range(start, end, step=1) that works like built‑in range but yields values lazily. Test with list(my_range(0, 10, 2)) → [0, 2, 4, 6, 8].
2. File Line Yielder
Write a generator that reads a text file and yields lines that are not empty (strip whitespace). Use yield inside a with block.
3. Zip Generator Expression
Use a generator expression to create pairs (i, i**2) for i in range(5). Convert to list and print it.
⚡ Intermediate (3 problems)
4. Take N from an Infinite Generator
Given an infinite generator (e.g., itertools.count()), write a function take(n, generator) that returns the first n items as a list.
5. Yield from a Nested Structure
Write a generator flatten(nested_list) that yields all non‑list elements from a nested list structure (e.g., [1, [2, [3, 4], 5]] → 1,2,3,4,5). Use recursion with yield from.
6. Sliding Window Generator
Create a generator function window(iterable, n) that yields overlapping windows of size n. Example: list(window('ABCDE', 3)) → ['ABC', 'BCD', 'CDE'].
🚀 Advanced (3 problems)
7. Generator Pipeline with tee()
Create a pipeline that reads numbers from a generator, and branches into two: one squares them, the other cubes them. Use itertools.tee(). Collect results from both branches.
8. Coroutine‑based Data Broadcaster
Write a generator that receives values via send() and broadcasts each value to multiple target generators (consumers). Use a loop with send() on each consumer.
9. Async Generator for Real‑time Sensor Simulation
Write an async generator that yields simulated temperature readings every 0.5 seconds, with random jitter. Collect 10 readings and print them. Use asyncio.sleep().