Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Iteration & Laziness  ›  Lesson

Chaining Generators into a Pipeline

Lazy Pipelines 24 minGenerator stages chained so each record crosses every stage before the next is read, and the whole chain stops when the consumer does
You're building a piece ofQuarry — the feed
This piece — pipe(): Parses, filters and limits log lines as a chain of stages, and stops reading the source once it has enough.
Scenario Quarry's preview shows the first few errors from a log that can run to ten million lines, and must stop reading once it has them.
Your task
Write the generator stages parse(lines) (skip blanks, yield {"level", "msg"} split at the first space), at_level(records, level) and take(items, n) (at most n items, stopping straight after the nth without pulling another, and pulling nothing when n is 0). Then pipe(lines, level, limit) chains them behind the provided counted(lines, tally) and returns {"records": [msg, ...], "pulled": n}. Example: the six-line log with level "ERROR" and limit 2 → {"records": ["disk full", "fan stuck"], "pulled": 4}.

Chaining Generators into a Pipeline

In short: Quarry never does just one thing to a log line — it reads, parses, filters, and stops once it has enough. The eager way runs each step over the whole input and hands the next step a list. The lazy way makes each step a generator that takes an iterable and yields, so the steps snap together like pipe sections: one record crosses every stage before the next is even read. Requests travel backward, from consumer to source; values travel forward, one at a time. That's what lets a lazy pipeline stop early and read only as much of a 200 MB log as the answer actually needs.

Four pipeline stages, read-parse-at_level-take, connected by green forward arrows for values and a dashed grey backward arrow for next() requests — the consumer pulls from the last stage, and the pull travels all the way back to the source

Chain four stages, and nothing runs yet

Every stage has the same shape: take an iterable, loop over it, yield. Because the input can be any iterable, it can be another stage:

python

Building the chain runs no code in any stage — that's why printing errors shows only a generator object. Work starts when the for loop at the bottom asks for the first record. The mistake people make here is picturing the eager version, where each stage finishes before the next begins:

What does this print?
def read(lines):
    for line in lines:
        print("r" + line, end=" ")
        yield line

def shout(lines):
    for line in lines:
        print("s" + line, end=" ")
        yield line.upper()

for x in shout(read(["a", "b"])):
    print(x, end=" ")

If you guessed ra rb sa sb A B, that's how a chain of lists behaves, with a full list built between every step. A lazy pipeline never builds that list — each stage holds one item at most, which is why its memory cost doesn't depend on the size of the input. The two shapes side by side:

Drawing diagram…

Stop a pipeline before it reads the whole log

The biggest win isn't memory — it's that a lazy pipeline can stop. Quarry's preview wants the first two errors from a file that might have ten million lines. Count how many lines actually leave the source:

What does this print?
pulled = 0

def source(n):
    global pulled
    for i in range(n):
        pulled += 1
        yield i

evens = (x for x in source(10**9) if x % 2 == 0)
first_two = next(evens), next(evens)
print(first_two, pulled)

A take(items, n) stage is how a pipeline says "enough" — it must stop the moment it has yielded its nth item, never pulling one more than needed:

step through it
1pulled = 0
2 
3def source(lines):
4 global pulled
5 for line in lines:
6 pulled += 1
7 yield line
8 
9def errors(lines):
10 for line in lines:
11 if line.startswith("ERROR"):
12 yield line[6:]
13 
14def take(items, n):
15 if n <= 0:
16 return
17 count = 0
18 for item in items:
19 yield item
20 count += 1
21 if count == n:
22 return
23 
24log = ["INFO boot", "ERROR disk", "INFO tick", "ERROR fan", "ERROR late", "INFO bye"]
25first = list(take(errors(source(log)), 2))
26print(first, pulled)

Four lines pulled out of six. Put if count == n at the top of the loop instead of after the yield, and take would pull one extra record before deciding it has enough — with a slow remote source, that's one wasted fetch every time. The standard library already has this stage: itertools.islice does exactly what take does, with the same discipline about not over-pulling, and itertools.chain joins several sources into one stream:

python

How far a lazy pipeline has to read depends on how common the thing you want is. At 10% errors, finding 20 of them reads about 200 lines; at 1%, about 2,000 — rarer matches mean reading further, but it still stops the moment it has enough. An eager pipeline reads every line regardless, so the same preview on a 10-million-line file costs 10 million reads either way.

Reuse a parsed stream for two different answers

Some operations — sorted(), counting how often each level appears — need the whole stream before they can produce anything. Put those last, after the filters have made the stream as small as possible; sorting 200 errors is cheap, sorting ten million lines first isn't.

Every stage is a generator, so every stage is single-use. That catches people the first time they try to get two different answers from one stream:

broken — fix it

Run it and look at infos. Then change the code so both lists come out right, without reading lines into anything but the two answers.

There's no error, just a silently empty list — the dangerous kind of bug. If two consumers need the same stream, either give each one its own fresh pipeline built from a function, or split the records in a single pass.

Quick practice

What does this print?
nums = (n for n in range(5))
doubled = (n * 2 for n in nums)
print(next(doubled), next(doubled), list(nums))
What does this print?
from itertools import islice

def forever():
    n = 0
    while True:
        yield n
        n += 1

print(list(islice(forever(), 3, 6)))

🎯 Your turn

Build Quarry's log pipeline from four stages, and prove it's lazy.

  • counted(lines, tally): yields every line unchanged, adding 1 to tally["pulled"] for each one it pulls (provided).
  • parse(lines): skips blank lines and yields {"level": ..., "msg": ...}, splitting each line at its first space.
  • at_level(records, level): yields only records at that level.
  • take(items, n): yields at most n items and stops as soon as it has yielded the nth, without pulling another. When n is 0 it yields nothing and pulls nothing.

Then pipe(lines, level, limit) chains them and returns {"records": [msg, ...], "pulled": n}:

  • pipe(["INFO boot", "ERROR disk full", "INFO tick", "ERROR fan stuck", "ERROR late", "INFO bye"], "ERROR", 2) → {"records": ["disk full", "fan stuck"], "pulled": 4}

Hint: take checks n <= 0 before its loop and count == n after each yield. Build the chain from the inside out: take(at_level(parse(counted(lines, tally)), level), limit).

Press ▶ Run, then ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upYour first two-stage pipeline

Write two generator stages: evens(numbers), which yields only the even numbers, and squares(numbers), which yields each number squared. squares_of_evens(numbers) (already written) chains them.

generator-pipelineslazy-pipeline
DrillFind one, read no further

Write first_match(lines, word), which returns {"line": ..., "pulled": n}: the first line containing word (or None if there isn't one), and how many lines were pulled from counted to find it. It should stop reading at the first match.

generator-pipelineslazy-evaluation
BuildA pipeline built from a config list

Quarry's config lists pipeline stages by name. Write run_stages(values, names), which applies the stages in STAGES in the order names gives them, each wrapping the one before, and returns the final list. An empty names returns the values unchanged.

generator-pipelinesfirst-class-functions
BossMerge two sorted streams

Two of Quarry's sources each deliver timestamps in sorted order, and they may be too big to hold. Write the generator merge_sorted(a, b), which yields every item from both in sorted order while holding only one item from each at a time (no sorted(), no list() of the inputs). merged(a, b) returns the list.

generator-pipelinesiteration-protocol
CapstoneGroup a stream into sessions

A sensor sends timestamps (seconds, sorted). A session is a run of timestamps where each is no more than gap after the one before. Write the generator sessions(times, gap), which yields [start, end, count] for each session as soon as it knows the session has ended, holding only the current session's summary, not its timestamps. session_list(times, gap) returns the list.

generator-pipelinesgenerator-statelazy-pipeline
ExplainWhy record one goes first

In Quarry's generator pipeline, the first log line is read, parsed, filtered and printed before the second line is even read. In the list-based version, every line is read before any is parsed. Explain why the generator version behaves this way, and name two practical consequences for a 200 MB log, one about memory and one about stopping early. Then name one kind of stage that can't work this way.

pipe.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
pipe(lines, level, limit) → dictParse, filter and limit the log lines lazily, and report how many lines were pulled from the source.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.