Python • Iterators & Generators

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.

Topics
1
2
3
4
5
6
7

📌 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.

01

Iterables & Iterators

iter()next()
02

Custom Iterators

__iter____next__
03

Generator Functions

yieldlazy
04

Generator Expressions

( ... )comprehension
05

Advanced Methods

send()throw()
06

itertools

countchaincycle
07

Production

pipelinesasync gen

Why Iterators & Generators Matter

What problems do they solve?

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.

Why are they required?

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.

Role in Production

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.

iterable_vs_iterator.py
# 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)
🔍 Key Insight: Iterators are consumed once. After 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.

custom_iterator.py
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
💡 Best Practice: Separate the iterable (returns fresh iterator) from the iterator (holds state). This allows multiple independent iterations. The first 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.

generator_function.py
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
🔍 Why generators? They are concise, memory‑efficient, and automatically implement __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.

generator_expression.py
# 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
💡 Performance Tip: For large datasets, generator expressions can drastically reduce memory. Compare 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.

advanced_generator.py
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
⚠️ Important: Always call 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.

FunctionDescriptionExample Output
count(10, 2)Infinite arithmetic progression10, 12, 14, 16, ...
cycle('AB')Cycle endlessly through iterableA, B, A, B, A, ...
repeat('x', 3)Repeat an object (times optional)x, x, x
chain('ab', 'cd')Concatenate multiple iterablesa, b, c, d
compress('ABCD', [1,0,1,0])Filter elements based on selectorsA, C
islice('ABCDEF', 2, 5)Slicing for any iteratorC, D, E
product('AB', '12')Cartesian product('A',1), ('A',2), ('B',1), ('B',2)
combinations('AB', 2)r‑length combinations('A','B')
itertools_demo.py
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')
💡 Pro Tip: Use 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.

production_patterns.py
# 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
🔍 Key Insight: Generator pipelines are lazy — each stage pulls data from the previous stage on demand. This avoids building intermediate lists and scales to infinite or very large datasets.
💡 Async Generators: Use async def + yield with async for. Perfect for streaming HTTP responses, database cursors, or real‑time sensor data.

Solved Practice Problems

🔰 Easy (3 problems)

1. Simple Counter Generator
def counter(start, end):
    while start <= end:
        yield start
        start += 1
for n in counter(1, 5):
    print(n)  # 1 2 3 4 5
2. Square Numbers Generator Expression
squares = (x*x for x in range(6))
print(list(squares))  # [0, 1, 4, 9, 16, 25]
3. Iterate Over Dictionary Keys
person = {"name": "Alice", "age": 30}
for key in person:  # dict is iterable (keys by default)
    print(key)

⚡ Intermediate (3 problems)

4. Filter Generator (yield with condition)
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]
5. Infinite Fibonacci Generator (truncated with islice)
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]
6. Chain Multiple Lists
from itertools import chain
combined = chain([1,2], ['a','b'], (True, False))
print(list(combined))  # [1,2,'a','b',True,False]

🚀 Advanced (3 problems)

7. Coroutine‑style Generator with send()
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.0
8. Lazy Line Parser for Large CSV
import 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)
9. Async Generator for Paginated API
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().