Python Generators and yield: One Piece at a Time
Generator functions, generator expressions and pipelines that process a large file line by line. After this, you can handle a file bigger than your RAM without loading it.
Course content
Sign in to save your progress.
-
Functions That Wrap Functions
-
Python Closures: Functions That Remember
-
Python Decorators in Real Code: Timing, Retry and Logging
-
-
-
Data That Doesn't Fit in Memory
-
Python Iterators: What a for Loop Actually Does
-
Python Generators and yield: One Piece at a Time
-
Python Context Managers: Clean Up Even When Things Fail
-
-
-
Catch Bugs Before You Run the Code
-
Python Type Hints Beyond the Basics: Optional, Union, Generics
-
Checking Types With mypy in a Real Project
-
-
-
Doing More Than One Thing at a Time
-
Python Concurrency Basics: Threads, Processes or asyncio?
-
Python asyncio: Wait for Many Things Without Waiting Longer
-
-
-
Ship It, Then Make It Fast
-
Packaging a Python Project With pyproject.toml
-
Profiling Python Code: Measure Before You Optimize
-
-
- Final Check: Python Advanced
A generator produces values one at a time, only when asked, instead of building the whole result first. For small data, that difference doesn't matter. For a log file bigger than your RAM, it is the difference between a script that finishes and one that gets killed.
Skip ahead: If you already write generator functions, chain them into pipelines, and know when yield from helps, continue to "Python Context Managers: Clean Up Even When Things Fail."
yield: a function that pauses
def countdown(n):
print("starting")
while n > 0:
yield n
n -= 1
print("done")
gen = countdown(3)
print(gen)
print(next(gen))
print(next(gen))
print(list(gen))
<generator object countdown at 0x...>
starting
3
2
done
[1]
Calling countdown(3) doesn't run anything. It returns a generator, which is an iterator (see the previous lesson). Each next() runs the function until the next yield, hands out that value, and pauses there with all its local variables intact. When the function ends, the generator raises StopIteration for you. That is why done prints before [1]: list() collected the last value, then the function finished, and only then did print show the list.
The pager, rewritten
The previous lesson wrote a Pages class with __iter__ and __next__. As a generator:
def pages(items, size):
for start in range(0, len(items), size):
yield items[start:start + size]
for page in pages(list(range(1, 8)), 3):
print(page)
[1, 2, 3]
[4, 5, 6]
[7]
Same result, no class, no position counter, no StopIteration. Python keeps track of where the function paused.
Why it matters: memory
Here is a test with a log file of 500,000 lines. One version reads it into a list, the other uses a generator. tracemalloc measures the peak memory Python used:
import tracemalloc
with open("app.log", "w", encoding="utf-8") as f:
for i in range(500_000):
level = "ERROR" if i % 1000 == 0 else "INFO"
f.write(f"2026-10-01 12:00:00 {level} request {i} handled in 12ms\n")
def errors_list(path):
with open(path, encoding="utf-8") as f:
lines = f.readlines() # the whole file in memory
return [line for line in lines if " ERROR " in line]
def errors_gen(path):
with open(path, encoding="utf-8") as f:
for line in f: # one line at a time
if " ERROR " in line:
yield line
for func in (errors_list, errors_gen):
tracemalloc.start()
found = sum(1 for _ in func("app.log"))
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(f"{func.__name__}: {found} errors, peak {peak / 1_000_000:.1f} MB")
errors_list: 500 errors, peak ... MB
errors_gen: 500 errors, peak 0.0 MB
When this was tested for the course, the list version peaked at about 53 MB and the generator version at well under 0.1 MB (it rounds to 0.0). Your exact numbers will differ by Python version and system. The pattern won't: the list version grows with the file, the generator version stays flat. Make the file 20 GB and the list version runs out of memory while the generator version keeps going.
Pipelines: small generators chained together
The real strength is chaining. Each step takes an iterator and yields to the next, and only one line is in flight at a time:
def read_lines(path):
with open(path, encoding="utf-8") as f:
yield from f
def only_errors(lines):
return (line for line in lines if " ERROR " in line)
def request_ids(lines):
for line in lines:
yield int(line.split()[4])
with open("app.log", "w", encoding="utf-8") as f:
for i in range(10_000):
level = "ERROR" if i % 2500 == 0 else "INFO"
f.write(f"2026-10-01 12:00:00 {level} request {i} handled in 12ms\n")
ids = request_ids(only_errors(read_lines("app.log")))
print(list(ids))
[0, 2500, 5000, 7500]
Each function does one job and can be tested on its own with a short list instead of a file. yield from f means "yield every item of f". only_errors uses a generator expression, the round-bracket version of a comprehension, which is fine for one-line steps.
⚠ Warning: In read_lines, the file stays open while the generator is paused. If the consumer stops early and the generator is never finished, the file is only closed when the generator is garbage collected. For a script that is fine. In a long-running service that opens many files, finish the generator, or call .close() on it, or open the file outside and pass the lines in.
Taking only what you need
Because nothing runs until it is asked for, stopping early is free:
from itertools import islice
def squares():
n = 1
while True:
print(f"computing {n}")
yield n * n
n += 1
print(list(islice(squares(), 3)))
computing 1
computing 2
computing 3
[1, 4, 9]
An endless generator is safe as long as the consumer stops. list(squares()) would never finish, so be careful who you hand it to.
When not to use a generator
- You need the data more than once. A generator is used up after one pass. If you need to sort, count and then process, a list is simpler, as long as it fits in memory.
- You need
len()or indexing. Generators have neither. - The data is small. A list of 200 rows is fine. Using a generator there adds nothing and makes debugging slightly harder, because you can't just print it.
Common mistakes
- Printing a generator to see what's inside. You get
<generator object ...>. Convert a small one withlist(), or take a few withislice. - Calling
list()on a huge generator. That loads everything into memory again and cancels the whole point. - Expecting code before the first
yieldto run immediately. It runs on the firstnext(). Validation errors in a generator function appear when the loop starts, not when you call it.
💡 Tip: If a function builds a list just to return it, and every caller only loops over the result once, replace result.append(x) with yield x and remove the list. The callers' for loops keep working unchanged.
What happens if you skip this step
Scripts that read whole files into lists work for months, while the files are small. Then the data grows, and one day the script slows down, the machine starts swapping, and the process is killed with no Python error at all. Writing it as a generator from the start costs nothing extra.
Your log file doubles in size every year. Which part of your current script would fail first, and in which year?
About
Generator functions, generator expressions and pipelines that process a large file line by line. After this, you can handle a file bigger than your RAM without loading it.
- Duration
- 10 min
- Last update
- 09/01/2026
Comments (0)
Sign in to commentNo comments yet.