Python Generators: How to Process Massive Datasets Without Crashing Your Computer
The ‘Memory Full’ Wall
Have you ever tried to open a massive CSV file, only to watch your computer turn into a very expensive paperweight? Your cursor freezes, the fan whirrs like a jet engine, and Python finally throws that dreaded message: MemoryError.
Here’s what’s happening underneath. When you create a standard Python list, you’re asking your computer to build the entire finished product and store it in your RAM. It’s like cooking a meal for 1,000 people and putting every single plate on your kitchen counter at the same time. Unless you have a massive kitchen, you’ll run out of space.
Now let’s see what happens when we create a list with 50 million numbers.
import sys
try:
# Creating a list of 50 million integers
massive_list = [i for i in range(50000000)]
print(f"List size: {sys.getsizeof(massive_list) / (1024**2):.2f} MB")
except MemoryError:
print("Your computer just ran out of RAM!")
On a standard laptop, this list might take up close to 400 MB of RAM just for the structure itself — not counting the actual integers. Scale this to 500 million, or try loading a 10GB log file, and your system crashes. That’s the ‘Memory Full’ wall.
Instead of a list (the finished meal), we need a recipe — a generator. A generator doesn’t hold the data. It holds the instructions to make the data, one piece at a time.
Meet the ‘yield’ Keyword: Your Code’s Pause Button
In a normal function, you use return. Python hits return, the function is done — it hands over the result and clears its memory. yield works differently. Think of it as a ‘pause’ button.
When a function uses yield, it becomes a generator. Call it, and nothing runs yet. It just stands by. Ask for a value, and it runs until it hits yield, hands you that one value, then stays right there, waiting for the next request.
The tricky part: the function ‘remembers’ its state. Let’s compare a standard function to a generator.
def get_numbers_list(n):
result = []
for i in range(n):
result.append(i)
return result
def get_numbers_generator(n):
for i in range(n):
yield i
# The list function builds everything first
numbers = get_numbers_list(5)
print(f"List: {numbers}")
# The generator function just waits
gen = get_numbers_generator(5)
print(f"Generator object: {gen}")
# We get values one by one
print(f"First value: {next(gen)}")
print(f"Second value: {next(gen)}")
What this means in practice: the generator function only needs enough memory to hold one item at a time. The list function needs enough memory for all of them.
The Generator Expression: A One-Line Superpower
You probably know list comprehensions like [x for x in data]. They’re handy, but “eager”—the entire list gets built immediately.
Swap those square brackets [] for parentheses (), though, and you get a generator. That’s a generator expression.
import sys
# A list comprehension
list_comp = [i for i in range(1000000)]
# A generator expression
gen_exp = (i for i in range(1000000))
print(f"List size: {sys.getsizeof(list_comp)} bytes")
print(f"Generator size: {sys.getsizeof(gen_exp)} bytes")
On my machine, the list takes 8,448,728 bytes. The generator? 112 bytes.
Ten items or ten billion—the generator stays at 112 bytes. Why? Because it isn’t storing the numbers. It’s storing the logic of how to count to a million.
Real-World Data: Streaming a Massive CSV
Let’s apply this to a real data science problem. Say you have a CSV with 10 million rows of sales data, and you want only the rows where ‘price’ is over $100. pandas.read_csv() loads the whole file into RAM.
A generator lets you stream the file instead. RAM usage stays flat — even at 100GB.
import csv
def stream_expensive_items(filename):
with open(filename, mode='r') as f:
# csv.DictReader is a generator! It reads one line at a time.
reader = csv.DictReader(f)
for row in reader:
if float(row['price']) > 100:
yield row
# Let's imagine 'huge_data.csv' exists
# expensive_items = stream_expensive_items('huge_data.csv')
# for item in expensive_items:
# print(item)
Here’s the idea: we crack the file open, grab one row, check the price. Toss it if it’s cheap; hand it over if it’s expensive. Then the next row. Never more than one row in memory at a time. The upshot: you can process files larger than your RAM without slowing your computer.
The Catch: When Generators Aren’t the Answer
Generators sound like magic, but they have two real limitations.
First, they’re one-and-done. Loop through a generator and it’s exhausted. Think of a roll of film—once you’ve developed it, you can’t play it again without starting over and re-reading the source.
Second, no indexing. Want the 500th item? The generator has to run the logic for the first 499 to get there.
gen = (i for i in range(3))
# First pass
for val in gen:
print(val)
# Second pass - this will print NOTHING
for val in gen:
print("This won't run!")
try:
print(gen[0])
except TypeError as e:
print(f"Error: {e}")
If you need to sort, shuffle, or access specific rows multiple times, stick with a list (or a Pandas DataFrame). But for cleaning, filtering, or aggregating, generators are hard to beat.
Reach for a generator when:
- The source is larger than RAM — log files, big CSVs, database cursors, network streams.
- You only need each value once: a single-pass filter, map, or reduce (
sum(),max(), counting, writing rows to a file). - You want to start producing output before the entire input is ready (true streaming).
- The downstream code only iterates with a
forloop and never asks fordata[i].
Reach for a list (or a Pandas/Polars DataFrame) when:
- You need random access:
data[500], slicing, or lookup by position. - You need multiple passes over the same data — e.g. compute a mean, then subtract it from every row.
- You need to sort, shuffle, group, or join, all of which require holding the full dataset in memory to compare values against each other.
- The dataset comfortably fits in RAM, and you’d rather pay the memory cost once than rebuild the generator from source every time you need to re-iterate.
The core tradeoff in one sentence: lazy evaluation buys you O(1) memory but costs you re-iteration and indexing — and if downstream code ends up calling list(gen) to recover those abilities, you’ve paid for both the generator’s bookkeeping and the full materialized list, which is the worst of both worlds.
Smell test: if you find yourself writing data = list(my_generator) immediately after creating it, you didn’t want a generator. You wanted a list.
Two escape hatches worth knowing:
itertools.tee(gen, n)forks one generator intonindependent iterators — but it has to buffer values internally, so if the forks advance at different rates, memory creeps back toward list-size.itertools.islice(gen, start, stop)lets you grab a slice without indexing — handy for peeking at the first N rows of a stream (list(islice(gen, 10))) without materializing the whole thing.
Rule of thumb: if the answer to “do I need to look at any value more than once?” is yes, use a list. If it’s no, use a generator and let the memory savings compound through your whole pipeline.
Summary
So, here’s the recap:
- Lists are ‘eager’: every item sits in RAM.
- Generators are ‘lazy’: an item is produced only when you ask for it, which uses far less memory.
- The
yieldkeyword: it lets a function pause and resume. - The parentheses trick:
(x for x in data)builds a generator on the fly. - One-way street: generators run once and don’t support indexing.
Next time you hit a MemoryError, try yielding before upgrading your RAM.
Check Your Understanding
Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.
Remember
What is the key difference in memory usage between a list comprehension [x for x in data] and a generator expression (x for x in data)?
Understand
In your own words, explain why you can’t ask a generator for data[500] directly, using the article’s explanation of how a generator reaches its 500th item.
Apply
Using the article’s stream_expensive_items pattern, would this function’s memory usage change if the input CSV grew from 10 million rows to 100 million rows? Why or why not?
Analyze The article says generators are “one-and-done” — once exhausted, looping again prints nothing. Walk through what would go wrong if you passed the same generator object into two different functions that each expected to iterate over the full dataset (e.g., one computing a sum, the next computing an average).
Evaluate The article recommends generators for “cleaning, filtering, or aggregating data” but lists and DataFrames for sorting, shuffling, or repeated access. Critique a pipeline design that tries to stream a 50GB file through a generator chain end-to-end, including a step that needs to sort the data by a column: what has to change about the pipeline architecture at that specific step?
Create Design a generator-based pipeline for a new scenario: processing a 20GB server log file to count how many requests came from each unique IP address, without loading the whole file into memory. Sketch the generator function(s) you’d write and explain why the final counting step (which needs to remember every IP seen so far) doesn’t defeat the memory-saving purpose of the earlier streaming steps.
Related articles
- Why Is My Data Pipeline Crashing? A Friendly Guide) — when your pipeline falls over, this walks through the usual suspects (memory, schema drift, upstream shape changes) and how to triage them.
- Vectorization in Python: Why It’s 100-1000x Faster — once your data is in memory, the next speedup lever is replacing Python-level loops with NumPy/Pandas vectorized operations. Pair this article’s “don’t load it all” with that article’s “do less per row” for the full picture.
References & Further reading
- Python
itertoolsdocumentation — iterators for efficient looping, includingtee,islice,chain, and the recipes section: https://docs.python.org/3/library/itertools.html - Python
csvmodule documentation —DictReader,reader, and streaming file I/O: https://docs.python.org/3/library/csv.html
Apply What You Learned
Brief. You’ve inherited a batch-inference service that scores customer transactions from a 10-million-row CSV. It follows this article’s stream_expensive_items pattern — csv.DictReader yields one row at a time, each row is scored, predictions are collected. In dev, with a 1,000-row fixture, it returned correct predictions. When it shipped to staging with the real 10M-row file, it ran to completion but returned zero predictions — no MemoryError, no crash, just an empty list. The on-call engineer said “it’s like the rows vanished.”
Here’s the service:
import csv
import logging
logger = logging.getLogger(__name__)
def stream_rows(filename):
with open(filename) as f:
reader = csv.DictReader(f)
for row in reader:
yield row
def run_inference(filename, model):
rows = stream_rows(filename)
# Production monitoring: log total row count
total = sum(1 for _ in rows)
logger.info(f"Processing {total} rows from {filename}")
# Score every row
predictions = [model.predict(row) for row in rows]
return predictions
The total = sum(...) line was added during the staging deploy for observability — it wasn’t in the dev branch.
Deliverable. Write a 150–250 word incident postmortem that (1) names the exact one-line bug, (2) explains the mechanism — why sum(1 for _ in rows) causes the list comprehension on the next line to produce [] — using the article’s “one-and-done” / StopIteration language, and (3) proposes a fix that preserves the streaming design (flat RAM regardless of file size). Your fix must not be rows = list(stream_rows(filename)) — explain why that would re-introduce the 400 MB+ memory spike the article warned about and defeat the generator’s 112-byte advantage.
Rubric:
- Bug identified:
total = sum(1 for _ in rows)exhausts the generator before the inference pass runs. - Mechanism explained:
sum(1 for _ in rows)iterates the generator to completion, internally hittingStopIteration. When the list comprehension[model.predict(row) for row in rows]callsnext(rows)again, it immediately receivesStopIteration— the loop body never executes, sopredictionsstays[]. References the article’s “secondforloop silently does nothing” behavior. - Dev-vs-staging accounted for: The monitoring line was added in staging, not dev — so dev’s generator was never pre-exhausted and inference worked normally with the 1,000-row fixture.
- Fix preserves streaming: Proposes a single-pass fix (e.g., count and predict in one
forloop, or callstream_rows(filename)a second time for the inference pass) — notlist(stream_rows(...)). - Rejects the
list()smell: Explicitly notes thatlist(stream_rows(filename))would materialize all 10M rows into RAM — the sameMemoryErrorscenario as the article’s 50-million-integer example (~400 MB+ for the list structure alone) — and that the generator’s whole point was its constant ~112-byte footprint regardless of dataset size.
Related articles
- Python Engineering Under review
Why is My Data Pipeline Crashing? A Friendly Guide to Python Memory Profiling
Learn to diagnose and fix Python MemoryError crashes in data pipelines using memory_profiler, Fil, and chunking to handle massive datasets on limited RAM.
- Python Engineering Under review
Vectorization in Python: Why It's 100–1000x Faster Than Loops
Learn how to replace slow Python loops with NumPy vectorized operations for 100–1000x speedups using SIMD, broadcasting, boolean masking, and Pandas built-ins.
- Python Engineering Under review
Parallelization in Python: multiprocessing, joblib, and When More Workers Backfire
Learn how to parallelize your Python loops with multiprocessing and joblib, when it delivers real speedups, and when more workers backfire instead.
- Python Engineering Under review
The 'Wait, I Need a Database for This?' Problem
Learn how DuckDB lets you run fast SQL queries directly on CSV and Parquet files without spinning up a database server—columnar performance with zero setup overhead.
Looking for something else?
Search every article by title, summary or topic.