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 StopIteration
Key point: for loops automatically call __iter__() and__next__() behind the scenes

Iterables 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!
Remember: Iterables can be iterated over multiple times. Iterators can only be used once.

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]
Pro tip: Use () 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()
Best practice: Use generators for processing large files, infinite sequences, or data pipelines

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))   # 18
Advanced: Use yield from to chain generators and send() for two-way communication with generators

Practical 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 arrangements
Remember: itertools functions return iterators, making them memory-efficient for large datasets

Generator 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
Rule of thumb: Use generator expressions by default unless you specifically need a list

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 it
Warning: Generators can only be iterated once. Plan accordingly or recreate them as needed.

Key Takeaways

  • Iterators implement __iter__() and __next__()
  • Generators use yield to produce values lazily
  • Generator expressions use (), list comprehensions use []
  • Generators are memory-efficient for large datasets
  • yield from delegates to other generators
  • itertools provides powerful iterator utilities
  • Generators can only be iterated once - they get exhausted
  • Use generators for data pipelines, file processing, and infinite sequences

Quick Reference

ConceptSyntaxUse Case
Iterator Class__iter__(), __next__()Custom iteration logic
Generator Functiondef func(): yield valueSimple iteration, lazy evaluation
Generator Expression(x for x in iterable)One-line generators, memory efficiency
yield fromyield 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