Lazy data pipelines

itertools · Advanced · 12 min

Log analysis, ETL and data cleaning over huge inputs stay fast and memory-light when each stage is lazy.

A pipeline is a series of lazy stages, each taking an iterator and returning another. Data flows through one item at a time, so memory stays constant no matter how large the input:

lines   = open(path)
records = (parse(l) for l in lines if l.strip())
errors  = (r for r in records if r.level == "ERROR")
first20 = islice(errors, 20)

Nothing runs until the last stage is consumed. If you stop early (here, after 20 errors), the earlier stages stop too, so most of the file is never read.

Design tips:

  • Make each stage a small generator function or generator expression with one job: read, parse, filter, transform, batch.
  • Use itertools for structural steps: chain.from_iterable to merge sources, islice to limit, groupby for sorted runs, batched to write in chunks.
  • Keep side effects (writing output, network calls) at the end of the pipeline.
  • Close resources: wrap file opening in a generator using with, so the file closes when the generator finishes or is garbage-collected, or consume within a with block.

Laziness makes errors appear late: a parse error surfaces when the record is reached, not when the pipeline is built. Include line numbers (via enumerate) in records to make errors traceable.

Example

from itertools import batched, chain, islice

logs = {"a.log": "INFO start\nERROR disk\n\nINFO ok\n",
        "b.log": "ERROR net\nERROR disk\nINFO bye\n"}
for name, text in logs.items():
    open(name, "w").write(text)

def read_lines(path):
    with open(path) as f:             # closed when the generator finishes
        for n, line in enumerate(f, 1):
            yield path, n, line.rstrip("\n")

lines = chain.from_iterable(read_lines(p) for p in logs)
records = ((p, n, *l.split(" ", 1)) for p, n, l in lines if l)
errors = (r for r in records if r[2] == "ERROR")
for batch in batched(islice(errors, 3), 2):
    print(batch)
# (('a.log', 2, 'ERROR', 'disk'), ('b.log', 1, 'ERROR', 'net'))
# (('b.log', 2, 'ERROR', 'disk'),)

Remember

  • Each pipeline stage is a lazy iterator; memory stays constant
  • Stopping the last stage early stops all earlier stages
  • Keep stages small and carry positions for traceable errors

Watch out for

  • Inserting a list() in the middle of a pipeline, which loads everything into memory and defeats laziness.

Check yourself

  1. A pipeline reads a 50 GB log lazily and ends with islice(errors, 10). Roughly how much of the file is read?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/itertools.html