Iterators & Generators
Master memory-efficient iteration in Python.
What are Iterators and Generators?
Iterators are objects that implement the iteration protocol, allowing you to traverse through data one item at a time. Generators are a special type of iterator that generate values on-the-fly, making them extremely memory-efficient for large datasets.
The Iterator Protocol
An iterator must implement two methods: __iter__() which returns the iterator object itself, and __next__() which returns the next value or raises StopIteration when done.
class Counter:
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
if self.current > self.end:
raise StopIteration
self.current += 1
return self.current - 1
# Using the iterator
counter = Counter(1, 5)
for num in counter:
print(num)
# Manual iteration
counter = Counter(1, 3)
print(next(counter))
print(next(counter))
print(next(counter))
# One past the end raises StopIteration
try:
next(counter)
except StopIteration:
print("StopIteration raised")Expected Output:
1 2 3 4 5 1 2 3 StopIteration raised
What a for loop does behind the scenes
Figure 1: for calls iter() once to get an iterator, then next() repeatedly until StopIteration signals the loop to end.
for loops automatically call __iter__() and__next__() behind the scenesIterables vs Iterators
An iterable is any object that can return an iterator. An iterator is the object that does the actual iteration.
class MyRange:
def __init__(self, start, end):
self.start = start
self.end = end
def __iter__(self):
# Return a new iterator each time
return MyRangeIterator(self.start, self.end)
class MyRangeIterator:
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
if self.current >= self.end:
raise StopIteration
self.current += 1
return self.current - 1
# Iterable can be used multiple times
my_range = MyRange(1, 4)
print(list(my_range))
print(list(my_range)) # works again: __iter__ builds a fresh iterator
# Iterator gets exhausted
iterator = iter(my_range)
print(list(iterator))
print(list(iterator)) # exhausted: nothing left to yieldExpected Output:
[1, 2, 3] [1, 2, 3] [1, 2, 3] []
Generator Functions with yield
Generators are a simpler way to create iterators. Use yield instead of return to produce values one at a time.
def countdown(n):
"""Generator that counts down from n to 1"""
while n > 0:
yield n
n -= 1
# Using the generator
for num in countdown(5):
print(num)
# Generators are lazy - values computed on demand
gen = countdown(3)
print(next(gen))
print(next(gen))
print(next(gen))
# The generator is now finished
try:
next(gen)
except StopIteration:
print("StopIteration raised")
# Generator expressions (like list comprehensions)
squares_gen = (x**2 for x in range(5))
print(list(squares_gen))Expected Output:
5 4 3 2 1 3 2 1 StopIteration raised [0, 1, 4, 9, 16]
() for generator expressions and [] for list comprehensions. Generators use less memory!Memory Efficiency
Generators shine when working with large datasets because they generate values on-demand instead of storing everything in memory.
import sys
# List - stores all values in memory
def get_squares_list(n):
result = []
for i in range(n):
result.append(i ** 2)
return result
# Generator - computes values on demand
def get_squares_gen(n):
for i in range(n):
yield i ** 2
# Memory comparison
list_result = get_squares_list(1000)
gen_result = get_squares_gen(1000)
print(sys.getsizeof(list_result)) # grows with the data
print(sys.getsizeof(gen_result)) # constant, whatever n is
# Both produce the same result
print(sum(get_squares_list(100)))
print(sum(get_squares_gen(100)))
# Reading large files efficiently
def read_large_file(file_path):
"""Memory-efficient file reading"""
with open(file_path) as f:
for line in f:
yield line.strip()Expected Output:
8856 200 328350 328350
n while the generator stays constant. Note also that getsizeof measures the container alone, not the integers it refers to, so it is a fair comparison here but not a total memory figure.yield, yield from, and send()
Generators support advanced features like delegating to other generators and receiving values during iteration.
# yield from - delegate to another generator
def gen1():
yield 1
yield 2
def gen2():
yield 3
yield 4
def combined():
yield from gen1()
yield from gen2()
print(list(combined()))
# Flattening nested lists
def flatten(nested_list):
for item in nested_list:
if isinstance(item, list):
yield from flatten(item)
else:
yield item
nested = [1, [2, 3, [4, 5]], 6]
print(list(flatten(nested)))
# send() - sending values to generator
def accumulator():
total = 0
while True:
value = yield total
if value is None:
break
total += value
acc = accumulator()
next(acc) # Prime the generator
print(acc.send(10)) # running total after each send()
print(acc.send(5))
print(acc.send(3))Expected Output:
[1, 2, 3, 4] [1, 2, 3, 4, 5, 6] 10 15 18
yield from to chain generators and send() for two-way communication with generatorsPractical Generator Examples
Real-world scenarios where generators provide elegant solutions.
from collections import deque
from itertools import islice
# 1. Infinite sequence generator
def fibonacci():
"""Generate infinite Fibonacci sequence"""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# islice stops an infinite generator after n values
print(list(islice(fibonacci(), 10)))
# 2. Batch processing
def batch_data(data, batch_size):
"""Yield data in batches"""
for i in range(0, len(data), batch_size):
yield data[i:i + batch_size]
items = list(range(10))
for batch in batch_data(items, 3):
print(batch) # the last batch is short
# 3. Data pipeline
def read_csv(filename):
"""Read CSV lines"""
with open(filename) as f:
for line in f:
yield line
def parse_csv(lines):
"""Parse CSV lines"""
for line in lines:
yield line.strip().split(',')
def filter_rows(rows, column, value):
"""Filter rows by column value"""
for row in rows:
if row[column] == value:
yield row
# Chain generators for data processing
# rows = filter_rows(parse_csv(read_csv('data.csv')), 2, 'active')
# 4. Moving window
def window(iterable, size=2):
"""Sliding window over iterable"""
it = iter(iterable)
win = deque(maxlen=size)
for item in it:
win.append(item)
if len(win) == size:
yield tuple(win)
data = [1, 2, 3, 4, 5]
print(list(window(data, 3)))Expected Output:
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34] [0, 1, 2] [3, 4, 5] [6, 7, 8] [9] [(1, 2, 3), (2, 3, 4), (3, 4, 5)]
Built-in Iterator Tools
Python's itertools module provides powerful building blocks for working with iterators.
from itertools import (
count, cycle, chain, islice, zip_longest,
takewhile, dropwhile, combinations, permutations
)
# Infinite iterators
counter = count(10, 2) # 10, 12, 14, 16...
print(list(islice(counter, 5)))
cycler = cycle([1, 2, 3]) # 1, 2, 3, 1, 2, 3...
print(list(islice(cycler, 7)))
# Combining iterators
list1 = [1, 2, 3]
list2 = ['a', 'b', 'c', 'd']
print(list(chain(list1, list2)))
print(list(zip_longest(list1, list2, fillvalue=0)))
# Filtering
nums = [1, 2, 3, 4, 5, 6, 7, 8]
print(list(takewhile(lambda x: x < 5, nums))) # stops at the first failure
print(list(dropwhile(lambda x: x < 5, nums))) # skips until the first failure
# Combinations and permutations
items = ['A', 'B', 'C']
print(list(combinations(items, 2))) # order does not matter
print(list(permutations(items, 2))) # order mattersExpected Output:
[10, 12, 14, 16, 18]
[1, 2, 3, 1, 2, 3, 1]
[1, 2, 3, 'a', 'b', 'c', 'd']
[(1, 'a'), (2, 'b'), (3, 'c'), (0, 'd')]
[1, 2, 3, 4]
[5, 6, 7, 8]
[('A', 'B'), ('A', 'C'), ('B', 'C')]
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]itertools functions return iterators, making them memory-efficient for large datasetsGenerator Expressions vs List Comprehensions
Understanding when to use each can significantly impact performance.
# List comprehension - evaluates immediately
list_comp = [x**2 for x in range(10)]
print(type(list_comp))
print(list_comp)
# Generator expression - evaluates lazily
gen_exp = (x**2 for x in range(10))
print(type(gen_exp))
print(gen_exp) # the object itself, not its values
# (the hex address differs on every run)
print(list(gen_exp))
# When to use list comprehensions:
# - Need to iterate multiple times
# - Need to use list methods (append, sort, etc.)
# - Dataset is small
# When to use generator expressions:
# - One-time iteration
# - Large datasets
# - Chaining operations
# - Memory constraints
# Chaining generator expressions
nums = range(1000000)
result = sum(x for x in (n**2 for n in nums) if x % 2 == 0)
# Memory efficient - only one value in memory at a time
# Passing to functions that accept iterables
print(sum(x**2 for x in range(100))) # No need for list()
print(max(x**2 for x in range(100))) # Works directlyExpected Output:
<class 'list'> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] <class 'generator'> <generator object <genexpr> at 0x730166566b50> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] 328350 9801
Common Pitfalls
Avoid these common mistakes when working with iterators and generators.
# Pitfall 1: Exhausted iterators
gen = (x for x in range(3))
print(list(gen))
print(list(gen)) # exhausted, second pass yields nothing
# Solution: Recreate or use a list
def get_gen():
return (x for x in range(3))
# Pitfall 2: Modifying during iteration
# BAD - removing shifts the remaining items left, so the loop skips one
my_list = [1, 2, 4, 5]
for i in my_list:
if i % 2 == 0:
my_list.remove(i)
print(my_list) # the 4 survived: not what we asked for
# GOOD - build a new list instead of mutating the one being iterated
my_list = [1, 2, 4, 5]
my_list = [i for i in my_list if i % 2 != 0]
print(my_list)
# Pitfall 3: Forgetting to prime generator with send()
def my_gen():
while True:
value = yield
print(f"Received: {value}")
gen = my_gen()
# Sending before priming is an error
try:
gen.send(10)
except TypeError as e:
print(f"TypeError: {e}")
next(gen) # Prime it
gen.send(10) # Now it works
# Pitfall 4: Generators in class methods
class DataProcessor:
def __init__(self):
self.data = [1, 2, 3, 4, 5]
def process(self):
# Returns generator, not processed data
return (x * 2 for x in self.data)
processor = DataProcessor()
result = processor.process()
print(result) # a generator, not the values
# (the hex address differs on every run)
print(list(result)) # must consume it to get the valuesExpected Output:
[0, 1, 2] [] [1, 4, 5] [1, 5] TypeError: can't send non-None value to a just-started generator Received: 10 <generator object DataProcessor.process.<locals>.<genexpr> at 0x7bc3ca7ca8e0> [2, 4, 6, 8, 10]
Key Takeaways
- Iterators implement
__iter__()and__next__() - Generators use
yieldto produce values lazily - Generator expressions use
(), list comprehensions use[] - Generators are memory-efficient for large datasets
yield fromdelegates to other generatorsitertoolsprovides powerful iterator utilities- Generators can only be iterated once - they get exhausted
- Use generators for data pipelines, file processing, and infinite sequences
Quick Reference
| Concept | Syntax | Use Case |
|---|---|---|
| Iterator Class | __iter__(), __next__() | Custom iteration logic |
| Generator Function | def func(): yield value | Simple iteration, lazy evaluation |
| Generator Expression | (x for x in iterable) | One-line generators, memory efficiency |
| yield from | yield from other_gen() | Delegating to sub-generators |
| send() | gen.send(value) | Two-way communication |
| next() | next(iterator) | Manual iteration |
| iter() | iter(iterable) | Get iterator from iterable |
Practice Exercise
Challenge: Create a generator function that yields prime numbers up to a given limit. Then use it to find the sum of all prime numbers below 100.
Show Solution
def primes(limit):
"""Generate prime numbers up to limit"""
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
for num in range(2, limit + 1):
if is_prime(num):
yield num
# Find sum of primes below 100
result = sum(primes(100))
print(result)
# Memory efficient - only one prime in memory at a time!Expected Output:
1060
What's Next?
You've mastered iterators and generators! Now let's learn how to modify and enhance functions with decorators.
- Function Decorators - Learn to wrap functions and add functionality without modifying their code
- Class Decorators - Apply decorators to entire classes for powerful metaprogramming
- Built-in Decorators - Master @property, @staticmethod, and @classmethod