
Python Course
Generators in Python
In Lesson 25, you built iterators by writing a full class with __iter__ and __next__. It worked — but it required a lot of boilerplate for something conceptually simple. Generators are Python's answer to that problem. With a single keyword — yield — you can turn any function into an iterator instantly, with no class, no manual state tracking, and no StopIteration management needed.
Generators are one of the most powerful and memory-efficient tools in Python. Data science pipelines, web servers, file processors, and async frameworks all rely on them. This lesson covers generator functions, generator expressions, infinite generators, pipelines, yield from, and the send() method.
The yield Keyword — How it Works
A regular function runs to completion and returns one value. A generator function uses yield instead of (or alongside) return. Each time yield is reached, the function pauses, hands the value to the caller, and freezes its entire state — local variables, the instruction pointer, and the call stack. The next call to next() resumes exactly where it left off.
def count_up(start, end):
print(f" [generator] starting at {start}")
n = start
while n <= end:
print(f" [generator] about to yield {n}")
yield n # pause here — freeze state and hand n to caller
print(f" [generator] resumed after yield {n}")
n += 1
print(" [generator] done")
gen = count_up(1, 3)
print("Generator created — no code has run yet")
print()
print("Calling next() #1:")
val = next(gen)
print(f" Caller received: {val}")
print()
print("Calling next() #2:")
val = next(gen)
print(f" Caller received: {val}")
print()
print("For loop (gets the rest):")
for n in gen:
print(f" Loop received: {n}")
- Calling a generator function does not run any of its code — it returns a generator object immediately.
- Code runs only when
next()is called — one step at a time, always resuming from the exact pause point. - When the function body ends naturally (or hits a bare
return),StopIterationis raised automatically. - The generator object satisfies the full iterator protocol — use it anywhere an iterator is expected.
Generator vs Regular Function — The Memory Difference
The most important practical advantage of generators is memory. A list comprehension builds every value and stores them all in RAM at once. A generator produces values one at a time and discards each after use.
import sys
# List comprehension — builds ALL 1 million values immediately
big_list = [x ** 2 for x in range(1_000_000)]
print("List size :", sys.getsizeof(big_list), "bytes")
# Generator expression — holds only ONE value at a time
big_gen = (x ** 2 for x in range(1_000_000))
print("Gen size :", sys.getsizeof(big_gen), "bytes")
# Both produce identical results when consumed
print("First 5 from list:", big_list[:5])
big_gen2 = (x ** 2 for x in range(5))
print("First 5 from gen :", list(big_gen2))
# Generator is ideal when you only need to process once
total = sum(x ** 2 for x in range(1_000_000)) # never builds a list
print("Sum (generator) :", total)
- The list uses ~8MB. The generator uses ~104 bytes — regardless of how many values it could produce.
- When you only need to iterate once (compute a sum, find a max, process line by line), a generator saves all that memory.
sum(x**2 for x in range(1_000_000))— the generator expression goes directly insidesum()with no list ever built.
Generator Expressions
Generator expressions are the lazy equivalent of list comprehensions — same syntax, but with parentheses instead of square brackets. They are ideal for one-shot processing.
# Generator expressions — one-liner lazy sequences
squares_list = [x ** 2 for x in range(1, 6)] # list — builds now
squares_gen = (x ** 2 for x in range(1, 6)) # generator — lazy
print("List :", squares_list)
print("Gen :", list(squares_gen))
# Pass directly to functions — no extra () when it's the only argument
print("Sum :", sum(x ** 2 for x in range(1, 6)))
print("Max :", max(x ** 2 for x in range(1, 6)))
# Filter inline
evens = (x for x in range(1, 20) if x % 2 == 0)
print("Evens:", list(evens))
# Chain transformations inline
result = sum(x ** 2 for x in range(1, 100) if x % 3 == 0)
print("Sum of squares of multiples of 3 (1-99):", result)
- Use
()for a generator expression,[]for a list comprehension,{}for set/dict comprehensions. - When a generator expression is the only argument to a function, the outer
()can be dropped:sum(x for x in range(5)). - Generator expressions are consumed once — if you need to iterate multiple times, use a list or a generator function that can be called again.
Multiple Yield Points and Early Return
A generator function can have multiple yield statements, conditional logic, and a return statement to stop early. Each yield is a pause point.
def fibonacci(limit):
"""Yields Fibonacci numbers up to limit."""
a, b = 0, 1
while a <= limit:
yield a
a, b = b, a + b # advance both values
print("Fibonacci up to 100:", list(fibonacci(100)))
def classify_numbers(numbers):
"""Yields (number, label) tuples."""
for n in numbers:
if n < 0:
yield n, "negative"
elif n == 0:
yield n, "zero"
elif n % 2 == 0:
yield n, "even"
else:
yield n, "odd"
data = [-3, 0, 4, 7, -1, 12, 5]
for n, label in classify_numbers(data):
print(f" {n:>4} → {label}")
- A generator can hold any logic — conditionals, loops, multiple yields — it is a regular function that pauses.
- The Fibonacci generator is the classic example — the state (a, b) is naturally preserved between yields.
- A bare
returninside a generator raisesStopIterationimmediately.
Infinite Generators
Because generators produce values lazily, they can represent sequences that never end — impossible with a list. The caller controls when to stop.
import itertools
def integers_from(n):
"""Yields every integer from n upward, forever."""
while True:
yield n
n += 1
def primes():
"""Yields prime numbers, forever."""
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
n = 2
while True:
if is_prime(n):
yield n
n += 1
# islice — take first N values safely
first_10 = list(itertools.islice(integers_from(1), 10))
print("First 10 integers:", first_10)
first_10_primes = list(itertools.islice(primes(), 10))
print("First 10 primes :", first_10_primes)
# takewhile — take values while a condition holds
small_primes = list(itertools.takewhile(lambda x: x < 50, primes()))
print("Primes under 50 :", small_primes)
- Never call
list()orsum()on an infinite generator — it will loop forever. itertools.islice(gen, n)— take exactly n values.itertools.takewhile(pred, gen)— take values while condition holds.- The prime generator is a real example of where the iterator-class approach from Lesson 25 would be far messier.
Generator Pipelines
Generators compose naturally into pipelines — a chain of lazy transformations where each stage yields to the next. No intermediate lists are created, and memory stays flat no matter how many stages you add.
# Generator pipeline — zero intermediate lists
def read_lines(lines):
"""Stage 1: emit each line (simulates reading from a file)."""
for line in lines:
yield line
def strip_lines(lines):
"""Stage 2: strip whitespace from each line."""
for line in lines:
yield line.strip()
def filter_empty(lines):
"""Stage 3: drop empty lines."""
for line in lines:
if line:
yield line
def parse_csv_row(lines):
"""Stage 4: split each line into a list of fields."""
for line in lines:
yield line.split(",")
# Simulate raw data from a file
raw = [
" Alice, 28, Engineer \n",
" \n",
"Bob, 35, Designer\n",
" \n",
" Carol, 22, Developer \n",
]
# Build the pipeline — nothing runs yet
stage1 = read_lines(raw)
stage2 = strip_lines(stage1)
stage3 = filter_empty(stage2)
stage4 = parse_csv_row(stage3)
# Pull values through — computation happens only here
print(f"{'Name':<10} {'Age':>5} {'Role'}")
print("-" * 28)
for row in stage4:
name, age, role = [f.strip() for f in row]
print(f"{name:<10} {age:>5} {role}")
- Each generator pulls from the previous one — values flow through lazily one at a time.
- Only the final
forloop triggers actual computation — all four stages execute together per row. - This pattern handles files of any size with constant memory — the exact approach used in ETL pipelines and log processors.
yield from — Delegating to a Sub-generator
yield from delegates part of a generator's work to another iterable or generator. Instead of looping and yielding each value manually, you hand control to the sub-generator entirely — cleaner and more efficient.
# yield from — delegate to another iterable
def flatten(nested):
"""Recursively flattens nested lists of any depth."""
for item in nested:
if isinstance(item, list):
yield from flatten(item) # recurse into sub-list
else:
yield item
# Works at any depth
print(list(flatten([1, [2, 3], [4, [5, [6, 7]]]]]))
# yield from with multiple iterables in sequence
def combined():
yield from range(1, 4) # yields 1, 2, 3
yield from "abc" # yields 'a', 'b', 'c'
yield from [10, 20, 30] # yields 10, 20, 30
print(list(combined()))
# Compare: manual vs yield from
def manual_flatten(nested):
for sublist in nested:
for item in sublist: # two loops needed manually
yield item
def delegated_flatten(nested):
for sublist in nested:
yield from sublist # one line, same result
data = [[1,2,3],[4,5],[6,7,8,9]]
print(list(manual_flatten(data)))
print(list(delegated_flatten(data)))
yield from iterableis equivalent tofor item in iterable: yield item— but cleaner and slightly faster internally.- Works with any iterable — lists, ranges, strings, or other generators.
- Recursive flattening with
yield from flatten(item)is the canonical Python pattern for deep nesting.
Sending Values into a Generator — send()
Generators are not just one-way. The send() method lets you push a value into a running generator — the sent value becomes the result of the yield expression inside the function. This turns a generator into a coroutine — a function that can both produce and receive values.
def running_average():
"""Receives numbers via send() and yields the running average."""
total = 0
count = 0
average = None
while True:
value = yield average # yield current average, receive next number
if value is None:
break
total += value
count += 1
average = total / count
gen = running_average()
next(gen) # prime the generator — runs to the first yield
print(gen.send(10)) # send 10 → average is 10.0
print(gen.send(20)) # send 20 → average is 15.0
print(gen.send(30)) # send 30 → average is 20.0
print(gen.send(40)) # send 40 → average is 25.0
- You must always call
next(gen)first (orgen.send(None)) to advance to the firstyield— this is called "priming" the generator. yield averagedoes two things: it sendsaverageout to the caller, and it receives the nextsend()value as its result.- This is the foundation of Python's
asynciocoroutine system —async def/awaitare built on top of this send/receive mechanism.
Real-World Example — Large File Processor
import re
from collections import Counter
# Simulate a large log file (in production this reads from disk)
log_data = """
2024-03-15 08:01:22 INFO Server started
2024-03-15 08:05:31 INFO User alice logged in
2024-03-15 08:12:44 ERROR Database timeout
2024-03-15 08:15:02 WARN Disk at 85%
2024-03-15 08:22:18 ERROR SMTP refused
2024-03-15 08:30:05 INFO User bob logged in
2024-03-15 09:00:01 ERROR Connection reset
2024-03-15 09:05:22 INFO Backup done
""".strip().splitlines()
# Generator pipeline for log analysis
def parse_log(lines):
pattern = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\w+)\s+(.*)")
for line in lines:
m = pattern.match(line)
if m:
yield {"time": m.group(1), "level": m.group(2), "msg": m.group(3)}
def only_level(entries, level):
for e in entries:
if e["level"] == level:
yield e
# Process without loading everything into memory at once
all_entries = list(parse_log(log_data))
level_counts = Counter(e["level"] for e in parse_log(log_data))
errors = list(only_level(parse_log(log_data), "ERROR"))
print("Log summary:")
for level, count in sorted(level_counts.items()):
print(f" {level:<8}: {count}")
print(f"\nErrors ({len(errors)}):")
for e in errors:
print(f" [{e['time']}] {e['msg']}")
Quick Reference Table
| Concept | Syntax | Key Benefit |
|---|---|---|
| Generator function | def f(): yield value | Lazy values, automatic state management |
| Generator expression | (expr for x in it) | Inline lazy sequence, minimal memory |
| Infinite generator | while True: yield n | Endless sequences at zero memory cost |
| Pipeline | Chain generator functions | Multi-stage transforms, zero intermediate lists |
yield from | yield from iterable | Delegate to sub-generator cleanly |
send(value) | gen.send(x) | Push value into generator (coroutine pattern) |
islice | itertools.islice(gen, n) | Take n values from any generator safely |
takewhile | itertools.takewhile(pred, gen) | Take values while condition holds |
Practice
What keyword turns a regular function into a generator function?
What does calling a generator function return before any code inside it runs?
What is the syntax difference between a list comprehension and a generator expression?
What does yield from iterable do?
Which itertools function safely takes a fixed number of values from an infinite generator?
What generator method lets you push a value into a running generator?
Quick Quiz
What happens when you call a generator function?
Why do generators use far less memory than lists for large sequences?
What happens to a generator's state when it reaches a yield statement?
Which of the following is a generator expression?
What is the risk of calling list() on an infinite generator?
What must you call before the first send(value) on a fresh generator?