Python Generators and Iterators: Process Large Data Without Blowing Up Memory
When Python scripts start consuming hundreds of megabytes of RAM, the instinct is often to reach for a faster language or a fancier database. More often than not, the real problem is much simpler: the code loaded an entire dataset into memory at once. Generators—Python's lazy evaluation workhorses—let you process data one item at a time, and they are one of the highest-leverage concepts you can add to your Python toolbox.
The Memory Problem in Plain Sight
Consider a common task: reading a large log file and counting how many lines contain the word "error". The straightforward approach looks harmless:
def count_errors(path):
with open(path) as f:
lines = f.readlines() # loads EVERYTHING into memory
return sum(1 for line in lines if "error" in line.lower())
For a 10 MB file this runs fine. For a 4 GB log file, readlines() will happily try to hold all 4 GB in RAM—and on a shared server with a 2 GB limit, the process is killed. The fix is a one-word change:
def count_errors(path):
with open(path) as f:
return sum(1 for line in f if "error" in line.lower())
Iterating over the file object directly produces one line at a time. The operating system streams it, and your memory footprint stays flat no matter how large the file is. This is the essence of a generator: it computes and yields one value, then pauses until the next value is requested.
What Exactly Is a Generator?
A generator is a special kind of iterator created either by a generator function or a generator expression. The defining feature is the yield keyword. When a function contains yield, calling it does not run the body—it returns a generator object that you can iterate.
def read_large_file(path):
"""Yield one line at a time from a potentially huge file."""
with open(path) as f:
for line in f:
yield line.strip()
Compare this to an ordinary function that builds and returns a full list:
def read_all_lines(path):
with open(path) as f:
return [line.strip() for line in f] # materializes the whole list
The generator version uses virtually constant memory. The list version scales with the file size. For interactive exploration of a 5 GB CSV, that difference is the difference between a responsive tool and a frozen machine.
Generator Expressions vs. List Comprehensions
Python offers a compact syntax for generators that mirrors list comprehensions. The only difference is parentheses instead of square brackets:
squares_list = [x*x for x in range(1_000_000)] # list: ~8 MB allocated at once
squares_gen = (x*x for x in range(1_000_000)) # generator: lazy, one at a time
Be careful with one subtlety: generators are single-use. Once consumed, they are exhausted. If you need to iterate twice, you must recreate the generator or store the results in a list.
gen = (x for x in range(5))
print(list(gen)) # [0, 1, 2, 3, 4]
print(list(gen)) # [] -- already exhausted!
Practical Patterns That Save Real Memory
1. Chunked Processing with islice
Sometimes you genuinely need a slice of a generator, but slicing syntax works only on sequences. The itertools.islice function steps through a generator lazily and returns a fixed number of items:
from itertools import islice
def process_in_chunks(collection, chunk_size=1000):
iterator = iter(collection)
while True:
chunk = list(islice(iterator, chunk_size))
if not chunk:
break
process_chunk(chunk) # your batch logic here
This pattern is ideal for feeding records into a database in manageable transactions instead of one huge commit.
2. Streaming Aggregations
Because generators yield values lazily, you can pipeline multiple transformations without ever materializing intermediate lists:
import re
from collections import Counter
def log_errors(path):
with open(path) as f:
pattern = re.compile(r"error\s*:\s*(\w+)")
for line in f:
match = pattern.search(line)
if match:
yield match.group(1)
code_counts = Counter(log_errors("app.log"))
print(code_counts.most_common(5))
The Counter still needs memory proportional to the number of distinct error codes, which is tiny, rather than the number of log lines.
3. The yield from Shortcut
Generator delegation lets one generator hand off to another cleanly. yield from forwards each item from an inner iterable, which is especially useful when composing data pipelines:
def read_lines(paths):
for path in paths:
with open(path) as f:
yield from f # delegate to the inner iterable
for line in read_lines(["a.log", "b.log", "c.log"]):
...
When NOT to Use a Generator
Generators are not a universal cure. Knowing their limits prevents misuse:
| Situation | Reach for a generator? | Why |
|---|---|---|
| Huge files, live streams, infinite sequences | ✅ Yes | Constant memory wins |
| Random access to elements | ❌ No | Generators have no index |
| Need to iterate the same data twice | ⚠️ Sometimes | Must recreate or store |
| Tiny datasets | 🤷 Either | Overhead not worth it |
| Random access / backtracking | ❌ No | One-pass only |
A generator is a one-way stream. You cannot rewind it, you cannot jump to element five without passing elements zero through four, and you cannot know its length without exhausting it. If your algorithm needs random access, keep a list or use a different structure.
A Complete Worked Example: Log Analytics in Constant Memory
Let's put the pieces together. This script reads a large application log, counts error levels, and reports the top five error types—all while keeping memory usage flat regardless of file size:
import re
from collections import Counter
from itertools import islice
PATTERN = re.compile(r"\[(?P<level>\w+)\]\s+.*?\b(?P<code>\w+Error)\b", re.IGNORECASE)
def scan_errors(path):
with open(path) as f:
for line in f:
match = PATTERN.search(line)
if match:
yield match.groupdict()
def report(path, limit=5):
level_counts = Counter()
code_counts = Counter()
for chunk in iter(lambda: list(islice(scan_errors(path), 5000)), []):
for entry in chunk:
level_counts[entry["level"]] += 1
code_counts[entry["code"]] += 1
print("Levels:", level_counts.most_common())
print("Top codes:", code_counts.most_common(limit))
if __name__ == "__main__":
report("app.log")
The iter(lambda: list(islice(...)), []) loop is a compact way to pull fixed-size batches until the generator is exhausted. Each batch is small, processed, and freed before the next batch arrives.
Advanced Tip: Sending Values Into a Generator
Generators can also receive values through send(), which turns them into lightweight coroutines. This is rarely needed for simple data processing, but it unlocks two-way communication:
def accumulator():
total = 0
while True:
received = yield total # yield current total, then wait for input
if received is not None:
total += received
acc = accumulator()
print(next(acc)) # 0 -- prime the generator
print(acc.send(10)) # 10
print(acc.send(5)) # 15
This pattern is the foundation for more advanced async patterns and stateful processing pipelines. For most everyday tasks you will never need it, but knowing it exists helps you recognize the underlying mechanism when you encounter it in library code.
Building Your Own Iterators with a Class
If you need custom iteration behavior combined with other methods, you can create an iterator class implementing __iter__ and __next__. The generator function is generally simpler, but the class form is worth knowing for situations where you need richer state:
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current
Practical Checklist for Lazy Data Processing
When you inherit a script that is drowning in memory, run through this checklist:
- Replace
readlines()with direct iteration over the file object. - Convert list comprehensions that feed into
sum,any,all, orCounterinto generator expressions. - Batch database operations instead of inserting row by row or all at once.
- Verify with
sys.getsizeofon a sample—though note generators do not report their would-be contents, so measure the list version you are replacing. - Profile with a memory tool if you have one available; otherwise, watch the resident set size with the system monitor.
Summary
Generators let Python handle data sets that would otherwise exhaust memory, and they encourage a clean, streaming style of programming. The core ideas are small but powerful:
-
yieldturns a function into a lazy generator. - Generator expressions mirror comprehensions but build nothing eagerly.
- One-pass, streaming algorithms fit generators perfectly.
- Use
itertools.islicefor lazy chunking andyield fromfor delegation. - Avoid generators when you need random access or repeated iteration.
The next time a script slows to a crawl or dies with an out-of-memory error, look for the silent readlines(). Replacing it with a generator is often a two-line change that transforms a fragile script into one that scales to files of any size.
Top comments (0)