The Story That Explains Iterators & Generators
Now compare that with Netflix. Netflix doesn't ship you all 100 episodes. It streams episode 1 when you click play, then episode 2 only when you finish episode 1. You get exactly one episode's worth of data at a time — on demand. If you stop at episode 3, episodes 4 through 100 were never even loaded.
The DVD boxset is a list (an iterable that holds everything in memory). Netflix is a generator (produces values one at a time, only when asked). Same 100 episodes — completely different memory footprint.
In Python, iterables, iterators, and generators are three closely related concepts that beginners often confuse. All three let you loop through data — but the way they store, compute, and hand out values is fundamentally different. Master them and you will write code that handles gigabytes with the same ease as a handful of rows.
All generators are iterators. All iterators are iterables. But the reverse is not true — a list is iterable but is not an iterator, and an iterator built with a class is not necessarily a generator. Understanding these three concentric circles is the whole game.
Containers & Iterables — The Foundation
A container is any Python object that holds a collection of values —
list, tuple, dict, set, and str
are the most common. An iterable is any object that implements the
__iter__ method, which means Python knows how to produce an iterator from it.
__iter__() method that returns an iterator.
iter() behind the scenes.
next() on it directly will fail.
# A list is iterable — but is NOT an iterator
list1 = [10, 20, 30]
print(hasattr(list1, '__iter__')) # True — it IS iterable
print(hasattr(list1, '__next__')) # False — it is NOT an iterator
# This FAILS:
next(list1)
# TypeError: 'list' object is not an iterator
# This WORKS — first convert to iterator using iter()
it = iter(list1)
print(next(it)) # 10
print(next(it)) # 20
print(next(it)) # 30
print(next(it)) # StopIteration raised
Every Python beginner writes next(my_list) at some point and gets a
TypeError. The list knows how to give you an iterator, but it
is not itself one. You must call iter() first. This distinction is what
lets you loop over the same list multiple times — each for asks for a
fresh iterator.
Iterators — The Stateful Cursor
An iterator is an object that remembers where it is in the sequence.
It implements two methods: __iter__ (which returns itself) and __next__
(which returns the next value, or raises StopIteration when done).
__iter__ simply returns the object itself. That is what makes
it work in a for loop — the loop calls iter(), gets back the
same iterator, and starts consuming.
next() function. Returns the next element in the
sequence, advancing the internal state. When there are no more values, it must raise
StopIteration.
StopIteration is
raised, the iterator is exhausted. To iterate again, you must build a new one
from the original iterable.
If you call iter() on an object that is already an iterator,
you get the same object back — not a fresh one. This is why you cannot "reset" an
iterator by calling iter() on it a second time.
Building a Custom Iterator — MyRange
Let's build our own iterator that mimics Python 2's xrange() — producing
numbers one at a time without ever holding the full list in memory.
class MyRange:
"""A minimal reimplementation of xrange as a class-based iterator."""
def __init__(self, start, end):
self.value = start
self.end = end
def __iter__(self):
return self # iterators return themselves
def __next__(self):
if self.value >= self.end:
raise StopIteration
current = self.value
self.value += 1
return current
# Use it exactly like the built-in range
r = MyRange(1, 5)
for num in r:
print(num, end=' ')
That is 15 lines of code to build a lazy sequence generator. Powerful — but verbose.
Python has a much shorter route to the same behaviour: the yield keyword.
Enter Generators — Iterators with Superpowers
yield
statement, it freezes in mid-air, hands the value back to the caller, and
keeps its local variables intact. The next call to next() thaws it out
and resumes exactly where it stopped.yield is the pause button that also delivers a package.
A generator is a special kind of iterator built using a function
containing at least one yield statement. The moment Python sees
yield anywhere in a function body, that function becomes a
generator function — calling it does not run the code, it just
returns a generator object.
def my_range(start, end):
"""A generator version of MyRange — 4 lines instead of 15."""
while start < end:
yield start
start += 1
# Calling the function does NOT execute the body
gen = my_range(1, 5)
print(gen)
# <generator object my_range at 0x7f9b...>
# Values are produced only on demand
for num in gen:
print(num, end=' ')
No class. No __iter__. No __next__. No manual
StopIteration. Just yield, and Python takes care of
everything. When execution reaches the end of a generator function
(or hits a bare return), Python automatically raises
StopIteration for you.
How yield Actually Works — Step by Step
gen = my_range(1, 5). Python sees yield inside the body and returns a generator object without executing a single line. Local variables are prepared but nothing runs yet.for loop calls next(gen). Now the function body starts executing — runs the while check, reaches yield start, and freezes. The value 1 is handed back.yield. start becomes 2. Loop continues, hits yield again, freezes, hands back 2.start < end becomes False. The function ends. Python automatically raises StopIteration.for loop catches StopIteration and exits cleanly. Any further call to next(gen) will keep raising StopIteration — the generator is exhausted forever.Generator Expressions — Comprehensions with Parentheses
Just as list comprehensions give you compact list creation, generator expressions
give you compact generator creation. The syntax is identical, except you use
parentheses () instead of square brackets [].
| Property | Value |
|---|---|
| Syntax | [x*x for x in range(10)] |
| Return type | list |
| Evaluated | All at once |
| Memory | Holds all elements |
| Reusable | Yes |
| Property | Value |
|---|---|
| Syntax | (x*x for x in range(10)) |
| Return type | generator |
| Evaluated | Lazily, one at a time |
| Memory | Tiny, constant |
| Reusable | No — single use |
# List comprehension — builds the whole list in memory
squares_list = [x * x for x in range(10)]
print(squares_list)
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Generator expression — builds a generator (nothing computed yet)
squares_gen = (x * x for x in range(10))
print(squares_gen)
# <generator object <genexpr> at 0x7f9b8c1a2d40>
# Consume values on demand
for sq in squares_gen:
print(sq, end=' ')
# 0 1 4 9 16 25 36 49 64 81
Memory & Performance — The Real Difference
This is where generators earn their keep. Let's measure the memory footprint of a list comprehension against a generator expression, using the same computation for 10 million elements.
import sys
# List comprehension — allocates everything
lst = [x * 2 for x in range(10_000_000)]
print(f"List memory: {sys.getsizeof(lst):,} bytes")
# Generator expression — allocates almost nothing
gen = (x * 2 for x in range(10_000_000))
print(f"Generator memory: {sys.getsizeof(gen):,} bytes")
The list holds all 10 million values, so it needs ~85 MB. The generator holds only the recipe for producing values — its size stays constant at a couple hundred bytes no matter how many values it will eventually yield. This is why generators are the go-to tool for processing large files, streaming data, and infinite sequences.
Speed Comparison — Summing 100 Million Numbers
import time
# List comprehension — build then sum
t = time.perf_counter()
total = sum([x for x in range(100_000_000)])
print(f"List: {time.perf_counter() - t:.2f}s")
# Generator expression — stream directly into sum
t = time.perf_counter()
total = sum(x for x in range(100_000_000))
print(f"Generator: {time.perf_counter() - t:.2f}s")
Practical Example 1 — Fibonacci Forever
A classic use of generators: producing an infinite sequence. A list of infinite Fibonacci numbers is impossible — a generator is not.
def fibonacci():
"""An infinite Fibonacci generator."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Take the first 10 Fibonacci numbers
fib = fibonacci()
for _ in range(10):
print(next(fib), end=' ')
Combined with itertools.islice
from itertools import islice
# Take Fibonacci numbers 20 through 30 without building a list
first_ten_from_20 = list(islice(fibonacci(), 20, 30))
print(first_ten_from_20)
Practical Example 2 — Reading a Huge File
Suppose you must scan a 20 GB server log for the word "ERROR". Loading the file with
file.readlines() would blow up your RAM. A generator handles it in
megabytes of memory.
def grep_errors(path):
"""Yield every line in a huge log file that contains 'ERROR'."""
with open(path) as f:
for line in f: # file objects are iterators too!
if 'ERROR' in line:
yield line.rstrip()
# Streams line-by-line — only one line in RAM at a time
errors = grep_errors('/var/log/app-20gb.log')
# Print the first 5 error lines without loading the file
for line, _ in zip(errors, range(5)):
print(line)
Generators compose. Pipe one into another to build a lazy processing pipeline:
lines → filtered → parsed → aggregated. Each stage only pulls when the
next stage asks. This is the exact pattern behind Unix pipes — and it lets you
process terabytes on a laptop.
Advanced — send(), close(), and yield from
Generators can do more than produce values. They can also receive them.
x as the value of the paused yield expression. Lets you feed data into a generator.
GeneratorExit inside it. Any finally block gets a chance to clean up.
E inside the generator at the paused yield. Useful for signalling errors from the outside.
for … yield loop and forwards send/throw/return transparently.
def echo():
"""A generator that echoes whatever you send into it."""
while True:
received = yield
print(f"Got: {received}")
e = echo()
next(e) # prime the generator (advance to first yield)
e.send('hello') # Got: hello
e.send(42) # Got: 42
e.close() # cleanly stops the generator
# yield from — delegate to sub-generators
def countdown(n):
while n > 0:
yield n
n -= 1
def combined():
yield from countdown(3)
yield from ['go!', 'done']
print(list(combined()))
# [3, 2, 1, 'go!', 'done']
The Three Concepts Side-by-Side
| Property | Iterable | Iterator | Generator |
|---|---|---|---|
| Implements | __iter__ |
__iter__ + __next__ |
Both (auto — via yield) |
| Built by | Class or literal (list, str, …) |
Class with __next__ |
Function with yield / genexpr |
| Stores values? | YES (often) | DEPENDS | NO — computes on the fly |
| Memory footprint | Proportional to size | Depends on state stored | Constant, tiny |
| Traversable multiple times? | YES | NO | NO |
| Can be infinite? | Rarely | YES | YES (easily) |
| Lines of code to create | 1 (literal) | ~10 (class) | 2–4 (function) |
| Examples | list, dict, str, tuple |
Custom class, iter(list) |
range-like, file objects, map, zip |
Every generator is an iterator. Every iterator is an iterable. But an iterable is not always an iterator, and an iterator is not always a generator. If you remember only one sentence about these three, remember that.
Common Pitfalls
for loop, it is empty. A second loop will get zero items. Convert to list() if you need to iterate more than once..send(x) on a fresh generator, you must call next() once to advance to the first yield. Otherwise you get TypeError.return value is stored as StopIteration.value — it is NOT the yielded output. Beginners expect it to appear in the loop; it does not.sum(x*x for x in nums) is faster and cheaper than sum([x*x for x in nums]). Drop the brackets whenever you feed straight into sum, max, any, all, or join.Real-World Case Study — Streaming CSV Aggregation
Suppose you have a 5 GB sales CSV file with 200 million rows, and you need the total revenue per country. Loading it into pandas would swap your machine to a halt. A generator pipeline handles it in a few hundred MB of RAM.
import csv
from collections import defaultdict
def read_rows(path):
"""Stage 1 — yield each row as a dict, one at a time."""
with open(path, newline='') as f:
yield from csv.DictReader(f)
def only_2026(rows):
"""Stage 2 — filter to 2026 sales only."""
for row in rows:
if row['date'].startswith('2026'):
yield row
def parse_amount(rows):
"""Stage 3 — parse the amount field once."""
for row in rows:
row['amount'] = float(row['amount'])
yield row
# Compose the pipeline — nothing runs yet
pipeline = parse_amount(only_2026(read_rows('sales_2026.csv')))
# Only NOW do we pull rows through — one at a time
totals = defaultdict(float)
for row in pipeline:
totals[row['country']] += row['amount']
for country, revenue in sorted(totals.items(), key=lambda x: -x[1])[:5]:
print(f"{country:20s} ${revenue:,.2f}")
The whole pipeline never keeps more than one row in memory at a time. The
totals dict is the only thing that grows — and it grows with the
number of unique countries, not the number of rows. This is the trick that lets
generators scale to any dataset your disk can hold.
Golden Rules
iter().
An iterator gives values on demand via next(). A
generator is an iterator built with yield. If you can
recite this in one breath, you understand the model.
sum, max, min, any,
all, or str.join. Drop the square brackets — it's faster and lighter.
list().
yield from to delegate to sub-generators.
It reads better than a manual loop and correctly forwards send(),
throw(), and return values.
next(my_list). Lists are iterables, not iterators. Use
next(iter(my_list)) if you truly need the first element as an iterator
operation — otherwise use indexing my_list[0].
return, Python
raises StopIteration for you automatically. Do not raise
it manually inside a generator — it's considered a bug in Python 3.7+.