Python & Data Science

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!")
- `sys.getsizeof(obj)` returns the size of an object in **bytes** as Python sees it in memory. - `1024**2` is one megabyte (1024 × 1024 bytes); dividing by it converts the byte count into MB for the printout. - The list comprehension `[i for i in range(50000000)]` is **eager** — Python builds all 50,000,000 integer objects and the list's internal pointer array *before* the name `massive_list` even exists. That's where the memory spike happens. - `MemoryError` is what Python raises when the operating system refuses to hand over any more RAM. It's not a bug in your code; it's the machine saying "no more room." - The `try/except` is here only so you can observe the failure mode cleanly instead of crashing the whole Python session.

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)}")
- `return result` in `get_numbers_list` builds the *entire* list first, then hands the finished list back. By the time `numbers` is assigned, all `n` values already live in RAM. - `yield i` does something very different: it **pauses** the function, emits a single value, and keeps the function's local frame (the loop variable `i`, the loop position, everything) alive on a hidden call stack. - Calling `get_numbers_generator(5)` doesn't run the body at all — it returns a **generator object**, a small stateful iterator. That's what `` in the printout means. - `next(gen)` resumes the paused function, runs until the next `yield`, and returns that value. Call `next(gen)` again and it resumes from the *exact same spot*, as if you'd never left. - The list version has already done all the work by the time the first `print` runs. The generator hasn't done any work yet at all — it's just standing by, waiting to be asked.

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")
- `[i for i in range(1000000)]` is a **list comprehension**: it allocates a real Python list and fills it with all one million integers before the name `list_comp` is bound. - `(i for i in range(1000000))` is a **generator expression**: same syntax, different brackets. It returns a single small generator object that holds only the *recipe* for producing values, not the values themselves. - `sys.getsizeof(gen_exp)` measures the size of the generator **object itself** — a tiny frame holding a reference to the `range` iterator and the loop variable. It does *not* measure the million integers the generator would eventually produce, because it hasn't produced them yet. - That's why the generator's size is ~112 bytes whether the range is `1000000` or `10**9`: the storage cost is independent of how many items *would* be produced. It only knows *how* to count, not what it would count up to.

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)
- `open(filename, mode='r')` returns a **file object** that's iterable line-by-line. It does *not* slurp the whole file into memory — it just holds an OS file descriptor and a small read buffer. - `csv.DictReader(f)` is itself an iterator: each call to `next(reader)` pulls one line from `f`, parses it into a `dict`, and hands it back. The previously-returned row is dropped, so only one row's worth of data lives in RAM at a time. - `with open(...) as f:` is a **context manager** — it guarantees the file handle is closed when the block exits, even if an exception is raised mid-stream or the caller stops pulling early. - `yield row` makes `stream_expensive_items` a generator, so the caller's `for` loop *pulls* one row at a time through the entire chain. Nothing runs until the consumer asks for the next value. - The whole pipeline is **pull-based / lazy**: the file is only read as fast as the downstream loop consumes it. That's what keeps RAM usage flat regardless of whether the file is 10 MB or 100 GB.

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}")
- `for val in gen` is syntactic sugar for "call `next(gen)` repeatedly until `StopIteration` is raised" — each iteration advances the generator's internal state one step. - After the first `for` loop finishes, the generator has hit `StopIteration` (its `range(3)` ran to the end). The internal state is now "exhausted" — there's no rewind button on a generator. - The second `for` loop calls `next(gen)` once, immediately receives `StopIteration`, and exits the loop body without ever running. No error is raised — it just silently does nothing. - `gen[0]` raises `TypeError` because generator objects define `__next__` (for iteration) but **not** `__getitem__` (for indexing). Random access isn't supported because reaching index 500 would require running 500 steps of logic — which defeats the entire point of being lazy. - If you need a fresh pass over the same data, you must **rebuild** the generator (call the function again, or re-evaluate the expression). You can't "reset" an existing, exhausted generator object.

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.

Generators and lists solve the same "produce a sequence of values" problem from opposite directions. Which one fits depends on what the **consumer** of the data needs to do with it.

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 for loop and never asks for data[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 into n independent 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:

  1. Lists are ‘eager’: every item sits in RAM.
  2. Generators are ‘lazy’: an item is produced only when you ask for it, which uses far less memory.
  3. The yield keyword: it lets a function pause and resume.
  4. The parentheses trick: (x for x in data) builds a generator on the fly.
  5. 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.


References & Further reading


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 hitting StopIteration. When the list comprehension [model.predict(row) for row in rows] calls next(rows) again, it immediately receives StopIteration — the loop body never executes, so predictions stays []. References the article’s “second for loop 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 for loop, or call stream_rows(filename) a second time for the inference pass) — not list(stream_rows(...)).
  • Rejects the list() smell: Explicitly notes that list(stream_rows(filename)) would materialize all 10M rows into RAM — the same MemoryError scenario 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.

Looking for something else?

Search every article by title, summary or topic.