Python Advance 📂 Advance topics · 1 of 3 42 min read

Python Iterators and Generators — Understanding yield, Lazy Evaluation, and Memory-Efficient Loops

Confused between iterable, iterator, and generator? This tutorial clears it up with a Netflix-vs-DVD analogy, then dives into how yield works step-by-step, generator expressions, memory savings of up to 400,000×, infinite Fibonacci streams, multi-stage generator pipelines for processing 20 GB files, advanced send() / close() / yield from patterns, and a final set of 7 non-negotiable golden rules for using them in production.

Section 01

The Story That Explains Iterators & Generators

The Netflix Binge vs The DVD Boxset
Imagine you buy a full DVD boxset of a 100-episode series. Every disc, every episode, sitting on your shelf — all 100 present at once, taking up an entire cabinet. You paid for everything upfront, even the episodes you may never watch.

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.

💡
The Core Insight

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.


Section 02

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.

📚 What Makes Something Iterable?
Rule 1
The object must define a __iter__() method that returns an iterator.
Rule 2
Python's for loop, in operator, and unpacking (a, b = x) all call iter() behind the scenes.
Rule 3
A list is iterable — but calling 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
⚠️
The Iterable ≠ Iterator Trap

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.


Section 03

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__
returns self
An iterator's __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__
returns next value
Called by the built-in next() function. Returns the next element in the sequence, advancing the internal state. When there are no more values, it must raise StopIteration.
🔒
Single-Use
consumed once
An iterator can only be traversed once. Once StopIteration is raised, the iterator is exhausted. To iterate again, you must build a new one from the original iterable.
🔑
Iterator Passed to iter() Returns Itself

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.


Section 04

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=' ')
OUTPUT
1 2 3 4

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.


Section 05

Enter Generators — Iterators with Superpowers

The Pause Button
A regular function runs from top to bottom, returns a value, and forgets everything. A generator function is different — when it hits a 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=' ')
OUTPUT
<generator object my_range at 0x7f9b8c1a2c50> 1 2 3 4
Why Generators Are Beautiful

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.


Section 06

How yield Actually Works — Step by Step

Diagram — The Pause/Resume Life-Cycle of a Generator
STEP 1 · CALL gen = my_range(1,5) function does NOT run STEP 2 · next() next(gen) execution starts STEP 3 · YIELD yield start value 1 returned STEP 4 · FREEZE start=1 saved locals kept intact on next call · resume from yield STEP 5 · WHEN CONDITION FAILS while start < end → False Python auto-raises StopIteration
Each next() call thaws the frozen function, runs until the next yield, then freezes again. Local variables survive between calls.
01
Function Call
You call 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.
02
First next() Call
The 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.
03
Second next() Call
Execution resumes on the line after yield. start becomes 2. Loop continues, hits yield again, freezes, hands back 2.
04
Repeat Until Exhausted
Steps 2–3 repeat for values 3 and 4. On the fifth call, start < end becomes False. The function ends. Python automatically raises StopIteration.
05
Loop Ends Silently
The for loop catches StopIteration and exits cleanly. Any further call to next(gen) will keep raising StopIteration — the generator is exhausted forever.

Section 07

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 [].

📋 List Comprehension
PropertyValue
Syntax[x*x for x in range(10)]
Return typelist
EvaluatedAll at once
MemoryHolds all elements
ReusableYes
⚡ Generator Expression
PropertyValue
Syntax(x*x for x in range(10))
Return typegenerator
EvaluatedLazily, one at a time
MemoryTiny, constant
ReusableNo — 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

Section 08

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")
OUTPUT
List memory: 89,095,160 bytes <- ~85 MB Generator memory: 200 bytes <- ~200 bytes
Diagram — Memory Footprint (10 Million Integers)
LIST [x*2 for x in ...] 89,095,160 bytes ≈ 85 MB GENERATOR (x*2 for x in ...) 200 bytes <- practically invisible at scale 0 ~42 MB ~85 MB 445,475× less memory
Same computation, same 10 million values — but the generator holds only the recipe, not the results.
📈
A 400,000× Reduction

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")
OUTPUT
List: 6.42s Generator: 4.87s <- ~24% faster, uses almost no memory

Section 09

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=' ')
OUTPUT
0 1 1 2 3 5 8 13 21 34

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)
OUTPUT
[6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229]

Section 10

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)
OUTPUT
2026-07-14 10:12:33 ERROR Database connection timed out 2026-07-14 10:12:41 ERROR Retry failed: pool exhausted 2026-07-14 10:13:02 ERROR Circuit breaker opened for /api/checkout 2026-07-14 10:14:15 ERROR Payment gateway 502 Bad Gateway 2026-07-14 10:15:00 ERROR Session store unreachable
🔥
The Generator Pipeline Pattern

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.


Section 11

Advanced — send(), close(), and yield from

Generators can do more than produce values. They can also receive them.

⚖️ Advanced Generator Methods
.send(x)
Resume the generator, sending x as the value of the paused yield expression. Lets you feed data into a generator.
.close()
Politely stop the generator by raising GeneratorExit inside it. Any finally block gets a chance to clean up.
.throw(E)
Raise exception E inside the generator at the paused yield. Useful for signalling errors from the outside.
yield from
Delegate iteration to another iterable/generator. Cleaner than a manual 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']

Section 12

The Three Concepts Side-by-Side

Diagram — The Concentric-Circle Rule
ITERABLE has __iter__() → can be used in for loop ITERATOR has __iter__ + __next__ → stateful, single-use GENERATOR built via yield [1, 2, 3] "hello" {"a": 1} (1, 2, 3) iter([1,2,3]) MyRange(1,5) (x*2 for x in r) Every generator ⊂ every iterator ⊂ every iterable
The nesting relationship visualised. A list sits in the blue ring only; a file object sits in the green ring; a yield-based function sits in the amber core.
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
🌱
The Concentric-Circle Rule

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.


Section 13

Common Pitfalls

Iterating Twice
A generator is single-use. Once you consume it in a for loop, it is empty. A second loop will get zero items. Convert to list() if you need to iterate more than once.
gen exhaustion bug
Forgetting to Prime
Before calling .send(x) on a fresh generator, you must call next() once to advance to the first yield. Otherwise you get TypeError.
TypeError on send
Using return with a Value
In a generator, return value is stored as StopIteration.value — it is NOT the yielded output. Beginners expect it to appear in the loop; it does not.
return != yield
Use for I/O Streams
Reading files, network sockets, or database cursors? Generators are the correct tool. They pull one chunk at a time and let backpressure work naturally.
large files, streaming APIs
Chain Them Into Pipelines
Read → filter → transform → aggregate — each stage a generator. Nothing is materialised until you consume the final result. Memory stays flat regardless of dataset size.
functional pipeline pattern
Prefer genexprs in Aggregations
Writing 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.
drop the []

Section 14

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.

Diagram — A Lazy Generator Pipeline (one row flows through at a time)
STAGE 1 read_rows() yield each row from disk row STAGE 2 only_2026() skip other years yield the survivors row STAGE 3 parse_amount() convert str → float yield parsed row row CONSUMER defaultdict() sum by country tiny dict grows 5 GB CSV · 200M rows RAM in use: ~250 MB constant · one row + growing country totals · never the whole file
Each stage is a generator. The consumer pulls one row through the whole pipeline, then the next. Nothing is materialised in bulk.
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}")
OUTPUT
United States $412,884,317.55 United Kingdom $198,743,206.12 Germany $156,220,884.03 India $128,904,551.90 Japan $ 94,672,110.44
🏆
200 Million Rows, 250 MB of RAM

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.


Section 15

Golden Rules

🌲 Iterators & Generators — Non-Negotiable Rules
1
An iterable can be converted to an iterator using 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.
2
Use generators for large or infinite data. If your dataset does not fit comfortably in memory, use a generator. If it is infinite in principle (Fibonacci, primes, sensor stream), you have no choice but to use one.
3
Prefer generator expressions over list comprehensions when the result feeds directly into sum, max, min, any, all, or str.join. Drop the square brackets — it's faster and lighter.
4
Remember that a generator can be consumed only once. If you need to iterate multiple times, call the generator function again for a fresh generator, or materialise the results with list().
5
For pipelines, use yield from to delegate to sub-generators. It reads better than a manual loop and correctly forwards send(), throw(), and return values.
6
Never write 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].
7
When a generator hits the end of its body or a bare return, Python raises StopIteration for you automatically. Do not raise it manually inside a generator — it's considered a bug in Python 3.7+.