Python Generators: How They Save Memory Without Magic
Learn how Python generators use a simple memory pattern to process large datasets without hogging RAM, with practical examples for log files and streaming pipelines.
I remember the first time I hit a MemoryError while processing a CSV file. The file wasn't even that large—maybe 500MB—but my naive code tried to load everything into a list at once. That's when I discovered Python generators, and honestly, it felt like a superpower.
Generators aren't about writing less code. They're about changing how Python handles memory under the hood. Let me show you what I mean.
The Problem: Lists Hog Memory
Here's a simple example that most beginners write:
def get_squares(n):
squares = []
for i in range(n):
squares.append(i * i)
return squares
# This creates a list of 10 million integers in memory
squares = get_squares(10_000_000)
On my machine, that list takes about 80MB of RAM. On a server with limited memory, this can crash your application. The issue is that get_squares returns the entire list at once.
The Generator Pattern: One Item at a Time
Now look at the generator version:
def generate_squares(n):
for i in range(n):
yield i * i
# This doesn't create all 10 million values at once
squares = generate_squares(10_000_000)
The memory usage? Almost nothing. A generator object itself takes only about 56 bytes, regardless of how many items it can produce. The difference is that a generator computes each value on demand and forgets it.
That's the core insight: generators trade memory for computation time. They recompute rather than store.
How Generators Pattern Memory
When you use a generator, Python doesn't allocate memory for all the values. Instead, it:
- Creates a generator object (tiny)
- Stores only the function's current state (where it paused, local variables)
- Computes the next value only when you call
next()or iterate
This has a beautiful consequence for memory-intensive operations.
Real Example: Processing Log Files
At PythonSkillset, we once had to analyze 2GB of server logs. Here's the naive approach:
def load_logs(filepath):
with open(filepath) as f:
return [line.strip() for line in f] # 2GB in memory!
logs = load_logs("server.log")
error_count = sum(1 for log in logs if "ERROR" in log)
This crashed our development server twice. The generator fix was simple:
def load_logs_generator(filepath):
with open(filepath) as f:
for line in f:
yield line.strip()
logs = load_logs_generator("server.log")
error_count = sum(1 for log in logs if "ERROR" in log)
Same result, but the memory usage dropped from 2GB to about 8KB. The generator reads one line, processes it, then moves on. The line object is garbage collected immediately.
The Memory Pattern to Remember
Generators follow a simple memory pattern:
- Input: One item from a source (file, database cursor, network stream)
- Processing: Transform that one item
- Output: Yield it, then forget it
This works because generators can be chained:
def read_lines(filepath):
with open(filepath) as f:
for line in f:
yield line
def filter_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
def parse_errors(errors):
for error in errors:
# Extract timestamp, code, message
parts = error.split(" | ")
yield {"time": parts[0], "code": parts[1], "msg": parts[2]}
# Pipeline uses constant memory
errors = parse_errors(filter_errors(read_lines("server.log")))
Each generator in the pipeline holds only one item at a time. You can process terabytes of data with a few hundred bytes of memory.
When Generators Don't Help
Generators aren't always better. If you need random access to items (like data[5000]), a list is better. Also, if you're processing small datasets (under 10,000 items), the overhead of generator function calls might actually be slower than a list.
But for large datasets, streaming data, or when you don't need all values simultaneously, generators are the memory-efficient choice.
The Takeaway
Generators aren't complex once you understand the memory pattern. They don't store everything. They compute on demand. That's it.
Next time you write a function that builds a large list, ask yourself: "Do I really need all these values at once?" If the answer is no, make it a generator. Your memory usage will thank you.
And if you're working with files, databases, or network streams? Always use generators. They transform memory-hungry operations into efficient, streaming pipelines that scale naturally.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.