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 –
forloops, 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
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())
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
⚙️ 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
⚙️ 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)
⚙️ 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]
⚙️ 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]
⚙️ 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)
⚙️ 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)
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.
| Function | What 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
⚙️ 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
itertoolsmodule – 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 forandasync yieldfor concurrent streaming - 🔹 Context managers – using
withwith generators viacontextlib.contextmanager - 🔹 Custom collections – implementing
__iter__on your own classes - 🔹 Functional programming –
map,filter,reducewith 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."