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) # 1, 2, 3, 4, 5
# Manual iteration
counter = Counter(1, 3)
print(next(counter)) # 1
print(next(counter)) # 2
print(next(counter)) # 3
# next(counter) # Raises StopIterationfor 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)) # [1, 2, 3]
print(list(my_range)) # [1, 2, 3] - works again!
# Iterator gets exhausted
iterator = iter(my_range)
print(list(iterator)) # [1, 2, 3]
print(list(iterator)) # [] - exhausted!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) # 5, 4, 3, 2, 1
# Generators are lazy - values computed on demand
gen = countdown(3)
print(next(gen)) # 3
print(next(gen)) # 2
print(next(gen)) # 1
# next(gen) # Raises StopIteration
# Generator expressions (like list comprehensions)
squares_gen = (x**2 for x in range(5))
print(list(squares_gen)) # [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.
# 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
import sys
list_result = get_squares_list(1000)
gen_result = get_squares_gen(1000)
print(sys.getsizeof(list_result)) # ~9000+ bytes
print(sys.getsizeof(gen_result)) # ~200 bytes
# Both work the same way
print(sum(get_squares_list(100))) # 328350
print(sum(get_squares_gen(100))) # 328350
# 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()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())) # [1, 2, 3, 4]
# 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))) # [1, 2, 3, 4, 5, 6]
# 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)) # 10
print(acc.send(5)) # 15
print(acc.send(3)) # 18yield from to chain generators and send() for two-way communication with generatorsPractical Generator Examples
Real-world scenarios where generators provide elegant solutions.
# 1. Infinite sequence generator
def fibonacci():
"""Generate infinite Fibonacci sequence"""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Use with itertools.islice to limit
from itertools import islice
print(list(islice(fibonacci(), 10))) # First 10 Fibonacci numbers
# 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) # [0,1,2], [3,4,5], [6,7,8], [9]
# 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"""
from collections import deque
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))) # [(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, repeat,
chain, islice, zip_longest,
filterfalse, takewhile, dropwhile
)
# Infinite iterators
counter = count(10, 2) # 10, 12, 14, 16...
print(list(islice(counter, 5))) # [10, 12, 14, 16, 18]
cycler = cycle([1, 2, 3]) # 1, 2, 3, 1, 2, 3...
print(list(islice(cycler, 7))) # [1, 2, 3, 1, 2, 3, 1]
# Combining iterators
list1 = [1, 2, 3]
list2 = ['a', 'b', 'c', 'd']
print(list(chain(list1, list2))) # [1, 2, 3, 'a', 'b', 'c', 'd']
print(list(zip_longest(list1, list2, fillvalue=0)))
# [(1,'a'), (2,'b'), (3,'c'), (0,'d')]
# Filtering
nums = [1, 2, 3, 4, 5, 6, 7, 8]
print(list(takewhile(lambda x: x < 5, nums))) # [1, 2, 3, 4]
print(list(dropwhile(lambda x: x < 5, nums))) # [5, 6, 7, 8]
# Combinations and permutations
from itertools import combinations, permutations
items = ['A', 'B', 'C']
print(list(combinations(items, 2))) # [('A','B'), ('A','C'), ('B','C')]
print(list(permutations(items, 2))) # All 2-element arrangementsitertools 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)) # <class 'list'> print(list_comp) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] # Generator expression - evaluates lazily gen_exp = (x**2 for x in range(10)) print(type(gen_exp)) # <class 'generator'> print(gen_exp) # <generator object at 0x...> print(list(gen_exp)) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] # 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 directly
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)) # [0, 1, 2]
print(list(gen)) # [] - exhausted!
# Solution: Recreate or use a list
def get_gen():
return (x for x in range(3))
# Pitfall 2: Modifying during iteration
my_list = [1, 2, 3, 4, 5]
# BAD - don't modify while iterating
for i in my_list:
if i % 2 == 0:
my_list.remove(i) # Dangerous!
# GOOD - use list comprehension or create new list
my_list = [i for i in my_list if i % 2 != 0]
# Pitfall 3: Forgetting to prime generator with send()
def my_gen():
while True:
value = yield
print(f"Received: {value}")
gen = my_gen()
# gen.send(10) # ERROR! Must call next() first
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) # <generator object> - not the values!
print(list(result)) # [2, 4, 6, 8, 10] - must consume itKey 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) # 1060
# Memory efficient - only one prime in memory at a time!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