DEV Community

Cover image for Data Processing Pipelines in Python Using Generators: Build Memory-Efficient ETL and AI Workflows
Mohammad Rajaei Monfared
Mohammad Rajaei Monfared Subscriber

Posted on

Data Processing Pipelines in Python Using Generators: Build Memory-Efficient ETL and AI Workflows

When processing large datasets, it's common to chain together multiple operations such as reading data, filtering records, transforming values, and writing the results somewhere else.

A straightforward implementation often creates intermediate lists after each step:

data = read_data()
filtered = filter_data(data)
transformed = transform_data(filtered)
save(transformed)
Enter fullscreen mode Exit fullscreen mode

While this works well for small datasets, it becomes inefficient when processing millions of records because every intermediate result is stored in memory.

A better approach is to use generator-based pipelines.

Generators allow data to flow through each processing stage one item at a time, making your code more memory-efficient, composable, and easier to maintain.

In this article, we'll build several generator pipelines ranging from simple text processing to ETL and computer vision workloads.


What Is a Generator Pipeline?

A generator pipeline consists of multiple independent stages.

Each stage:

  • Receives an iterable (usually another generator).
  • Processes each item.
  • Produces items using yield.

The output of one stage becomes the input of the next.

Data Source -> Read -> Filter -> Transform -> Save / Output

One of the biggest advantages is that only one item typically moves through the pipeline at a time.


Example 1: Processing a Large Text File

Suppose you have a multi-gigabyte log file and want to:

  • Ignore comments
  • Remove whitespace
  • Convert everything to uppercase

Each step becomes its own generator.

def read_lines(filename):
    with open(filename) as f:
        for line in f:
            yield line


def remove_comments(lines):
    for line in lines:
        if not line.startswith("#"):
            yield line


def strip_lines(lines):
    for line in lines:
        yield line.strip()


def uppercase(lines):
    for line in lines:
        yield line.upper()
Enter fullscreen mode Exit fullscreen mode

Connecting them is straightforward:

pipeline = uppercase(
    strip_lines(
        remove_comments(
            read_lines("log.txt")
        )
    )
)

for line in pipeline:
    print(line)
Enter fullscreen mode Exit fullscreen mode

Notice that nothing happens until the final loop starts consuming the generator.

At any point, only a single line is being processed.


Example 2: Building a Simple ETL Pipeline

Generator pipelines are a natural fit for ETL (Extract, Transform, Load).

Let's define a simple model.

from dataclasses import dataclass


@dataclass
class User:
    id: int
    name: str
    age: int
Enter fullscreen mode Exit fullscreen mode

Extract

def extract():
    users = [
        {"id": 1, "name": "Alice", "age": 22},
        {"id": 2, "name": "Bob", "age": 17},
        {"id": 3, "name": "Charlie", "age": 35},
    ]

    for user in users:
        yield user
Enter fullscreen mode Exit fullscreen mode

Filter

def filter_adults(users):
    for user in users:
        if user["age"] >= 18:
            yield user
Enter fullscreen mode Exit fullscreen mode

Transform

def transform(users):
    for user in users:
        yield User(**user)
Enter fullscreen mode Exit fullscreen mode

Load

def load(users):
    for user in users:
        print(user)
Enter fullscreen mode Exit fullscreen mode

Putting Everything Together

load(
    transform(
        filter_adults(
            extract()
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

Output:

User(id=1, name='Alice', age=22)
User(id=3, name='Charlie', age=35)
Enter fullscreen mode Exit fullscreen mode

Each stage has a single responsibility, making the pipeline easy to extend and test.


Example 3: Computer Vision Pipeline

Generator pipelines are especially useful in machine learning and computer vision, where images can consume significant memory.

def load_images(paths):
    for path in paths:
        image = cv2.imread(path)
        yield image


def resize(images):
    for image in images:
        yield cv2.resize(image, (224, 224))


def normalize(images):
    for image in images:
        yield image / 255.0


def predict(images, model):
    for image in images:
        yield model.predict(image[None])
Enter fullscreen mode Exit fullscreen mode

Usage:

predictions = predict(
    normalize(
        resize(
            load_images(image_paths)
        )
    ),
    model,
)

for prediction in predictions:
    ...
Enter fullscreen mode Exit fullscreen mode

Instead of loading every image into memory first, images are loaded, processed, and predicted one at a time.


Example 4: Processing Database Records

The same approach works for database queries.

def fetch_rows(cursor):
    while True:
        rows = cursor.fetchmany(1000)

        if not rows:
            break

        for row in rows:
            yield row
Enter fullscreen mode Exit fullscreen mode

Cleaning the records:

def clean(rows):
    for row in rows:
        row["name"] = row["name"].strip()
        yield row
Enter fullscreen mode Exit fullscreen mode

Saving them:

def save(rows):
    for row in rows:
        insert_into_new_db(row)
Enter fullscreen mode Exit fullscreen mode

Only small batches are fetched from the database, while individual rows continue flowing through the pipeline.


Using yield from

Sometimes a generator simply forwards another iterable.

Instead of manually iterating:

def flatten(data):
    for lst in data:
        for item in lst:
            yield item
Enter fullscreen mode Exit fullscreen mode

You can delegate using yield from.

def flatten(data):
    for lst in data:
        yield from lst
Enter fullscreen mode Exit fullscreen mode

Example:

data = [[1, 2], [3, 4], [5]]

print(list(flatten(data)))
Enter fullscreen mode Exit fullscreen mode

Output:

[1, 2, 3, 4, 5]
Enter fullscreen mode Exit fullscreen mode

yield from makes generator composition cleaner and more readable.


Generator Expressions

For simple transformations, generator expressions are concise and expressive.

numbers = range(10)

pipeline = (
    x * x
    for x in numbers
    if x % 2 == 0
)

print(list(pipeline))
Enter fullscreen mode Exit fullscreen mode

Output:

[0, 4, 16, 36, 64]
Enter fullscreen mode Exit fullscreen mode

They work particularly well for lightweight pipelines.


Building Configurable Pipelines

As applications grow, it's useful to compose stages dynamically.

from collections.abc import Iterable, Callable


def run_pipeline(data: Iterable, *stages: Callable):
    for stage in stages:
        data = stage(data)
    return data
Enter fullscreen mode Exit fullscreen mode

Now define a pipeline at runtime.

pipeline = run_pipeline(
    range(20),
    lambda xs: (x for x in xs if x % 2 == 0),
    lambda xs: (x * x for x in xs),
    lambda xs: (x + 1 for x in xs),
)

print(list(pipeline))
Enter fullscreen mode Exit fullscreen mode

Output:

[1, 5, 17, 37, 65, 101, 145, 197, 257, 325]
Enter fullscreen mode Exit fullscreen mode

This makes it easy to swap, reorder, or add processing stages without modifying existing code.


Why Use Generator Pipelines?

Generator pipelines provide several advantages over list-based processing.

  • Memory efficient — process one item at a time instead of storing intermediate collections.
  • Lazy evaluation — work happens only when data is requested.
  • Composable — each stage performs a single responsibility.
  • Reusable — stages can be mixed and matched across pipelines.
  • Scalable — ideal for files, database cursors, streams, APIs, and machine learning datasets.
  • Testable — each generator can be unit tested independently.

Where You'll See This Pattern

Generator pipelines appear in many real-world systems, including:

  • ETL and data engineering workflows
  • Log processing
  • Web scraping
  • CSV and JSON processing
  • Computer vision preprocessing
  • Machine learning data loaders
  • Streaming applications
  • API response processing

Once you start recognizing the pattern, you'll find opportunities to use it in many Python projects.


Conclusion

Python generators are more than a way to iterate over data—they're a powerful tool for building clean, modular, and memory-efficient processing pipelines.

By splitting your workflow into small generator stages, you can process datasets of virtually any size while keeping your code easy to understand, test, and extend.

If you're working with large files, databases, APIs, or AI workloads, generator pipelines are a technique worth adding to your Python toolbox.

Happy coding! 🚀

Top comments (0)