Python 3.12+ · Updated 2026

Python Iterators & Generators
Lazy, Memory‑Efficient Iteration

Master the iterator protocol, create custom iterators, write generator functions with yield, use generator expressions, and harness the power of itertools for data pipelines.

Why Iterators & Generators Matter

Efficient, lazy data processing is the foundation of modern Python

Python's iterator protocol is the backbone of the language's looping constructs. Every for loop, every list, dict, and set comprehension – even zip() and enumerate() – rely on iterators. Understanding how they work unlocks powerful patterns: processing massive datasets that don't fit in memory, building data pipelines, and writing more expressive, reusable code.

The core idea

An iterator is any object that produces values one at a time on demand. A generator is a special kind of iterator written with yield – the easiest way to create lazy sequences in Python.

🌍 The Significance of Iterators in the Programming World

Iterators are the universal abstraction for sequence traversal. Every programming language has some form of iteration – Python's approach is particularly elegant because it's built directly into the language syntax. Iterators enable:

  • ✓Memory efficiency – process data without loading everything into RAM
  • ✓Abstraction – separate how you traverse a collection from the collection itself
  • ✓Lazy evaluation – compute values only when needed, enabling infinite sequences
  • ✓Composability – chain iterators together to build data pipelines
  • ✓Clean syntax – for loops, comprehensions, and generator expressions are all built on the same protocol

Mastering iterators and generators makes you a more effective Python developer – you'll write code that's both memory‑friendly and expressive.

The Iterator Protocol

__iter__ and __next__ – the two methods that make iteration work

🔹 How Iteration Works Under the Hood

An iterable is any object that implements __iter__(), which returns an iterator. An iterator implements __next__(), which returns the next value or raises StopIteration when exhausted.

# Python's for loop is syntactic sugar for this:
numbers = [1, 2, 3]
iterator = iter(numbers)   # calls numbers.__iter__()

while True:
    try:
        value = next(iterator)   # calls iterator.__next__()
        print(value)
    except StopIteration:
        break

# Output: 1 2 3
🔍 Why this code is used: It shows the internal machinery that every for loop uses – understanding it demystifies iteration.

⚙️ How it operates: iter() calls the object's __iter__ method to get an iterator. next() calls __next__ repeatedly until StopIteration is raised, signalling the end.

📊 How it affects the program: The loop processes each element in order without needing an index or knowing the length.

🎯 How it stimulates the outcome: The values are printed one by one – the loop stops cleanly when the iterator is exhausted.

📌 Significance: This protocol is universal – every iterable in Python follows this pattern, making iteration consistent and predictable.

Built‑in Iterators

Python's core data structures are iterable out of the box

🔹 Lists, Dicts, Sets, Strings, and More

All of Python's collection types return iterators. The file iterator example uses io.StringIO to simulate a file – runs perfectly in the browser.

import io

# List iterator
fruits = ["apple", "banana", "cherry"]
it = iter(fruits)
print(next(it))   # apple
print(next(it))   # banana
print(next(it))   # cherry

# Dictionary iterator – yields keys
user = {"name": "Ana", "age": 28}
for key in user:
    print(key)   # name, age

# Use items() to get key-value pairs
for k, v in user.items():
    print(k, v)

# String iterator
for ch in "Python":
    print(ch)

# Simulated file iterator – reads line by line, memory‑efficient
data = "Line 1\nLine 2\nLine 3\n"
with io.StringIO(data) as f:
    for line in f:
        print(line.strip())
🔍 Why this code is used: It demonstrates that Python's most common data structures are iterable, and shows how to use iterators manually and with for loops.

⚙️ How it operates: Each collection returns an iterator object that knows how to traverse its elements. The for loop internally calls iter() and then next() until StopIteration.

📊 How it affects the program: You can iterate over any iterable without worrying about indexes – the iterator handles the state.

🎯 How it stimulates the outcome: The values are printed in order – dictionaries yield keys by default, strings yield characters.

📌 Significance: This uniformity is what makes Python loops so powerful – you can write for x in thing and it works for almost any thing.

Creating Custom Iterators

Write your own classes that support for loops

🔹 A Custom Iterator Class

Implement __iter__ (returns self) and __next__ (returns values until exhausted).

class CountDown:
    def __init__(self, start: int):
        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 n in CountDown(5):
    print(n)   # 5, 4, 3, 2, 1, 0
🔍 Why this code is used: It shows how to create a custom iterator by implementing the protocol – useful when you need iteration logic that isn't covered by built‑ins.

⚙️ How it operates: The class stores the current value. __iter__ returns the iterator itself (a common pattern). __next__ decrements the value and returns it, raising StopIteration when done.

📊 How it affects the program: The object can be used in for loops and other iteration contexts.

🎯 How it stimulates the outcome: The loop prints numbers from 5 down to 0 – the iterator manages the state internally.

📌 Significance: This pattern is the foundation of all custom iteration in Python – many libraries implement their own iterators this way.

🔹 Iterator That Supports Multiple Passes

If you need to iterate multiple times, return a new iterator from __iter__ each time.

class Range:
    def __init__(self, start: int, end: int):
        self.start = start
        self.end   = end

    def __iter__(self):
        return _RangeIterator(self.start, self.end)

class _RangeIterator:
    def __init__(self, start: int, end: int):
        self.current = start
        self.end     = end

    def __iter__(self):
        return self

    def __next__(self):
        if self.current >= self.end:
            raise StopIteration
        value = self.current
        self.current += 1
        return value

r = Range(0, 3)
print(list(r))   # [0, 1, 2]
print(list(r))   # [0, 1, 2] – fresh iterator each time
🔍 Why this code is used: Some containers need to be iterable multiple times – this pattern separates the iterable from its iterator.

⚙️ How it operates: Range is an iterable (not an iterator). Its __iter__ returns a new _RangeIterator each time. The iterator maintains the current position.

📊 How it affects the program: You can call list(r) multiple times and get the same result – each iteration starts from the beginning.

🎯 How it stimulates the outcome: Both list(r) calls produce [0, 1, 2] because a fresh iterator is used each time.

📌 Significance: This is how Python's built‑in containers (list, dict, etc.) work – they return a new iterator on each iteration, allowing multiple passes.

Generators – yield Magic

The easiest way to create lazy iterators in Python

🔹 Generator Functions

A function that uses yield instead of return is a generator. Each yield pauses the function and saves its state – when called again, it resumes right where it left off.

def fibonacci(limit: int):
    """Generate Fibonacci numbers up to limit."""
    a, b = 0, 1
    while a < limit:
        yield a
        a, b = b, a + b

for num in fibonacci(100):
    print(num)   # 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89

fibs = list(fibonacci(100))
print(fibs)
🔍 Why this code is used: Generators are the simplest way to create iterators – they handle all the state management for you.

⚙️ How it operates: When called, the generator function doesn't run immediately – it returns a generator object. Each yield pauses execution and returns a value. The local variables, loop state, and current position are all preserved.

📊 How it affects the program: The Fibonacci numbers are generated on‑the‑fly – no list is ever created unless you explicitly convert it.

🎯 How it stimulates the outcome: The loop prints each Fibonacci number as it's produced – memory use is constant regardless of how many numbers are generated.

📌 Significance: Generators are the go‑to tool for lazy sequences – they're easy to write and naturally integrate with Python's iteration protocol.

🔹 Infinite Generators

Generators can run forever – you control how many values to consume.

def infinite_counter(start: int = 0):
    """Count forever from start."""
    n = start
    while True:
        yield n
        n += 1

counter = infinite_counter(10)
print(next(counter))   # 10
print(next(counter))   # 11
print(next(counter))   # 12

import itertools
first_five = list(itertools.islice(infinite_counter(), 5))
print(first_five)   # [0, 1, 2, 3, 4]
🔍 Why this code is used: Infinite generators are useful for streaming data, generating IDs, or when you don't know how many values you'll need.

⚙️ How it operates: The while True loop never ends – each yield produces the next number, and the loop continues indefinitely.

📊 How it affects the program: You can take as many or as few values as you like – the generator doesn't pre‑compute anything.

🎯 How it stimulates the outcome: next(counter) returns successive numbers. itertools.islice takes a finite slice from an infinite sequence.

📌 Significance: Infinite generators demonstrate the power of laziness – you can work with conceptually infinite data without ever running out of memory.

🔹 Generators Are Single‑Use

Once exhausted, a generator cannot be reused – you must create a new one.

def squares(n: int):
    for i in range(n):
        yield i ** 2

gen = squares(3)
print(list(gen))   # [0, 1, 4]
print(list(gen))   # [] – generator is exhausted

gen2 = squares(3)
print(list(gen2))  # [0, 1, 4]
🔍 Why this code is used: It highlights a common pitfall – generators are stateful and cannot be reset.

⚙️ How it operates: Once all values are yielded, the generator is exhausted – subsequent calls to next() raise StopIteration. list(gen) consumes the generator.

📊 How it affects the program: After the first list(gen), the generator is empty – the second call returns an empty list.

🎯 How it stimulates the outcome: The first call prints [0, 1, 4], the second prints []. A fresh generator gives the expected result.

📌 Significance: Always remember that generators are consumable – if you need to iterate multiple times, create a new generator or convert to a list.

Generator Expressions

Lazy comprehensions – memory‑efficient and fast

🔹 Syntax: (expr for item in iterable if condition)

A generator expression is like a list comprehension, but it uses parentheses instead of brackets. It produces values lazily – one at a time – rather than building the entire list in memory.

# List comprehension – eager, uses memory for all items
squares = [x**2 for x in range(1_000_000)]   # ~28 MB

# Generator expression – lazy, uses almost no memory
squares_gen = (x**2 for x in range(1_000_000))   # ~48 bytes

# Sum of squares without materialising the list
total = sum(x**2 for x in range(1_000_000))   # generator expression
print(total)

# Filtering with generator expression
even_squares = (x**2 for x in range(100) if x % 2 == 0)
for val in even_squares:
    print(val)
🔍 Why this code is used: Generator expressions offer the same concise syntax as list comprehensions but with lazy evaluation – ideal for large data.

⚙️ How it operates: The expression is enclosed in parentheses – when iterated, it computes each value on demand. It doesn't allocate a list.

📊 How it affects the program: Memory usage stays constant – only one value is produced at a time. The sum() example processes a million numbers without storing them.

🎯 How it stimulates the outcome: The sum is computed efficiently – the filtered generator prints only even squares.

📌 Significance: Generator expressions are one of Python's most elegant features – they combine readability with memory efficiency.

The itertools Module

Powerful lazy combinators for any iteration problem

The itertools module is Python's standard library for building efficient, memory‑friendly iterators. It provides functions for combining, filtering, grouping, and transforming sequences – all lazily.

🔹 Common itertools Functions

import itertools

# chain – flatten multiple iterables
result = list(itertools.chain([1, 2], [3, 4], [5, 6]))
print(result)   # [1, 2, 3, 4, 5, 6]

# cycle – repeat an iterable forever
counter = 0
for color in itertools.cycle(["red", "green", "blue"]):
    print(color)
    counter += 1
    if counter > 5: break   # red, green, blue, red, green, blue

# islice – slice an iterator lazily
data = itertools.count(0, 2)
first_10 = list(itertools.islice(data, 10))
print(first_10)   # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

# groupby – group consecutive items by a key
data = ["apple", "apricot", "banana", "blueberry"]
for key, group in itertools.groupby(data, key=lambda x: x[0]):
    print(key, list(group))   # a ['apple', 'apricot']  b ['banana', 'blueberry']

# combinations, permutations, product
cards = ["A", "B", "C"]
pairs = list(itertools.combinations(cards, 2))   # AB, AC, BC
perms = list(itertools.permutations(cards, 2))   # AB, AC, BA, BC, CA, CB
product = list(itertools.product([1, 2], [3, 4])) # (1,3), (1,4), (2,3), (2,4)
🔍 Why this code is used: itertools provides battle‑tested, efficient building blocks for iteration – they're faster and more reliable than custom loops.

⚙️ How it operates: Each function returns an iterator – they don't materialise lists. chain concatenates, cycle repeats, islice takes a slice, groupby groups consecutive items, and combinations/permutations/product generate combinatorial structures.

📊 How it affects the program: You can chain these functions together without ever storing intermediate results – memory stays minimal.

🎯 How it stimulates the outcome: The outputs are produced lazily – you can process huge datasets with constant memory.

📌 Significance: itertools is one of Python's hidden gems – mastering it saves you from writing many tedious loops and makes your code more expressive.
FunctionWhat it does
chain(*iterables)Flattens multiple iterables into one
cycle(iterable)Repeats the sequence forever
islice(iterable, stop)Lazy slice – takes first n items
groupby(iterable, key)Groups consecutive equal items
combinations(iterable, r)All r‑length combinations (order doesn't matter)
permutations(iterable, r)All r‑length permutations (order matters)
product(*iterables)Cartesian product (nested loops)
zip_longest(*iterables)Zips, filling missing values

Lazy Evaluation in Practice

Why memory efficiency matters – and how to use it

🔹 Processing Log Data (In‑Memory Example)

This version uses an in‑memory io.StringIO object to simulate a file – so it runs perfectly in the browser without needing an external app.log file.

import io

log_data = """2026-06-21 INFO Application started
2026-06-21 ERROR Database connection timeout
2026-06-21 WARNING Memory usage high
2026-06-21 ERROR User login failed for 'admin'
2026-06-21 FATAL System crash - restarting
2026-06-21 INFO Shutdown complete"""

def process_log_from_string(data: str):
    """Yield parsed ERROR entries from a log string."""
    for line in io.StringIO(data):
        line = line.strip()
        if "ERROR" in line or "FATAL" in line:
            parts = line.split()
            yield {"timestamp": parts[0], "message": " ".join(parts[1:])}

for entry in process_log_from_string(log_data):
    print(entry)
    if entry["message"].startswith("FATAL"):
        print("⚠️  Fatal error found – stopping early")
        break
🔍 Why this code is used: It demonstrates a real‑world use case – processing log files line by line without loading the whole file into memory.

⚙️ How it operates: The generator function reads lines from StringIO (or a real file) and yields only those that match the criteria. The consumer can stop early.

📊 How it affects the program: Only one line is held in memory at a time – the generator pauses between each yield.

🎯 How it stimulates the outcome: It prints each error entry and stops when a fatal error is encountered – the processing is lazy and can be aborted early.

📌 Significance: This pattern is used in production for log processing, data ingestion, and ETL pipelines – it's memory‑efficient and flexible.

When to use lazy vs eager

Use lazy (generators, itertools): large files, infinite sequences, streaming data, pipelines where you only need one pass.
Use eager (lists, list comprehensions): small data, need random access, need to iterate multiple times, need to slice or index.

Iterator & Generator Best Practices

Write clean, memory‑efficient code

🎯

Prefer generators over lists for large data

If you don't need random access, use a generator expression or a generator function. You'll use a fraction of the memory.

🔄

Use itertools for complex loops

Before writing a custom loop, check if itertools already provides a lazy, efficient solution. It's faster and less error‑prone.

🧹

Remember generators are single‑use

If you need to reuse a sequence, convert it to a list once, or write a function that returns a new generator each time.

🏷️

Type‑hint generator functions

Use Generator[YieldType, SendType, ReturnType] or Iterator[YieldType] from typing to document your generators.

⚡

Chain generators for pipelines

Build data pipelines by chaining multiple generators – each stage transforms the data, and nothing is materialised until you consume it.

🧪

Test generators with next()

In unit tests, you can use next(gen) to assert individual yielded values without consuming the whole generator.

Frequently Asked Questions

Common doubts about iterators and generators

What's the difference between an iterator and an iterable?

+

An iterable is any object that can be passed to iter() – it implements __iter__. An iterator is an object that implements __next__ and returns values one at a time. All iterators are iterable (they return self from __iter__), but not all iterables are iterators (e.g. a list is iterable but not an iterator).

When should I use a generator vs a list comprehension?

+

Use a generator expression (parentheses) when working with large data or when you only need to iterate once. Use a list comprehension (brackets) when you need random access, need to iterate multiple times, or the data set is small enough to fit comfortably in memory.

Can a generator have a return statement?

+

Yes – a return in a generator function raises StopIteration. The value of the return (if any) is attached to the exception and can be captured with return_value = yield from gen in Python 3.3+. In most cases, you'll just let the generator run out of values naturally.

How do I handle exceptions inside a generator?

+

You can use try/except inside the generator function. If an exception is raised, the generator stops. You can also catch exceptions from the consumer side using generator.throw(exception) to inject an exception into the generator at the point of the last yield.

What is yield from?

+

yield from delegates iteration to another iterator. It yields each value from the sub‑iterator and forwards send() and throw() calls. It's used to flatten nested generators and to implement recursive generators cleanly.

How do I know if an object is an iterator?

+

Use isinstance(obj, collections.abc.Iterator) (or Iterable). Or check if it has both __iter__ and __next__ methods. In practice, you usually don't need to check – you just call iter(obj) and it works or raises TypeError.

🏁 Congratulations – You've Mastered Iterators & Generators!

You've unlocked one of Python's most powerful paradigms – lazy, memory‑efficient iteration.

✅ What You've Accomplished

  • ✅ Understood the iterator protocol – __iter__ and __next__
  • ✅ Used built‑in iterators from lists, dicts, strings, and files
  • ✅ Created custom iterators with __iter__ and __next__
  • ✅ Written generator functions with yield – including infinite generators
  • ✅ Used generator expressions for lazy comprehensions
  • ✅ Explored the itertools module – chain, cycle, islice, groupby, combinations, and more
  • ✅ Applied lazy evaluation to a real‑world log processing example
  • ✅ Learned best practices for writing memory‑efficient Python

🌍 Why This Matters Beyond This Page

Iterators and generators are everywhere in Python – from for loops to zip() to enumerate() to the entire asyncio ecosystem. The patterns you've learned are used in production code to process terabytes of log data, stream real‑time events, and build efficient data pipelines. Generators are the foundation of Python's asyncio (through async/await) and are a key tool for any Python developer working with large data.

📚 Next Steps

Now that you're comfortable with lazy iteration, explore:

  • 🔹 Asynchronous generators – async for and async yield for concurrent streaming
  • 🔹 Context managers – using with with generators via contextlib.contextmanager
  • 🔹 Custom collections – implementing __iter__ on your own classes
  • 🔹 Functional programming – map, filter, reduce with lazy iterators

💌 A Final Word

Iteration is at the heart of Python's design – the language's emphasis on readability and expressiveness is built on the iterator protocol. The next time you write a for loop, remember the elegant machinery that makes it work. And the next time you process a large file, reach for a generator – your memory will thank you.

Now go build something lazy and beautiful! 🐍

Poetic Bytes · 2026 · Made with ❤️ by Glenn Junsay Pansensoy

"Iteration is the heartbeat of computation – make it lazy, make it beautiful."