Be Software Architecture Wise
Be Wise Apps

Leer en español · Lire en français · Auf Deutsch lesen

Section 5.4 of 5.8 · Intermediate · 23 min read

Pipes and filters and pipeline architectures

Video lesson

5 min · narrated, with captions
Listen only (audio)
Read the transcript

Some problems are naturally a flow: data comes in, passes through a series of steps, and goes out. In this lesson you'll learn the style built for that, what limits its speed, and where it's weak.

Think of a car wash. Each car goes through a series of stations: soak, scrub, rinse, dry. Each station does one job, knows nothing about the others, and passes the car along a conveyor belt to the next. You can add a wax station without changing the others. And if drying is slow, cars start to queue in front of it. Pipes and filters works the same way for data: the stations are filters, and the conveyor is the pipe.

Here's a pipeline from the food-delivery platform. A producer, or source, starts the flow: it reads order events. A transformer changes each record. This one adds the restaurant and region. A tester checks a condition and passes each record on or drops it. Here it drops test orders. And a consumer, or sink, ends the flow by writing to the data warehouse. Between the filters, pipes like this one buffer the records and carry them one way. The key constraints: filters share no state, and each one knows only the format of what it reads and writes, never its neighbours.

Those constraints produce the benefits directly. Because a filter shares no state, it can be written and tested alone, with a list of input records and the outputs you expect. It can be reused in other pipelines, and filters can be added, removed or reordered just by changing how the pipeline is assembled. And because records flow one at a time, a filter can start on the first record before the previous one has finished the last. The stages run concurrently, and need memory for only a few records.

The style's best-known home is Unix. In 1964, Doug McIlroy wrote that programs should be coupled like a garden hose. Ken Thompson added pipes to Unix in 1973, and the idea of small tools joined by pipes made the style famous. Today it's the default for data engineering. Extract, transform, load jobs move yesterday's orders into a warehouse, one step at a time. A classic compiler is a pipeline too: characters become tokens, tokens become a syntax tree, then come checks, optimisation and machine code. Media and log processing work the same way. And one structure can run as a batch over a bounded input, or as a stream that never ends.

Now a worked example. The platform loads order events into its warehouse through four filters. On one worker, Parse handles fifty thousand records a second, Enrich eight thousand, Validate forty thousand and Load twelve thousand. A pipeline can go no faster than its slowest filter, so this one runs at eight thousand records a second. On a busy day there are a million order events. That takes a hundred and twenty five seconds. Enrich keeps no state, so you can run two copies in parallel, each taking half the records. That stage now manages sixteen thousand a second, and Load, at twelve thousand, becomes the bottleneck. The day's data now takes about eighty three seconds: a thirty three per cent saving, not the fifty per cent a naive reading suggests. Speeding up anything but the bottleneck changes nothing, so measure first.

Speed differences cause a second problem. Without control, Parse would push fifty thousand records a second towards an Enrich stage that drains eight thousand. The buffer would grow by forty two thousand records every second, until memory ran out. The answer is back pressure: make the fast stage slow down to the speed of the slow one. A Unix pipe does this automatically, because a full pipe blocks the writer. Errors need a policy for each filter. A malformed record shouldn't stop the pipeline: send it to a dead-letter queue with the reason. And because restarts mean some records are processed twice, sinks should be idempotent.

Use pipes and filters when the problem is naturally a one-way flow of data: ingestion, conversion, enrichment, validation and export. It also appears inside other styles, such as a nightly reporting job in a modular monolith. Avoid it as the main structure of interactive applications, where users expect immediate replies, and for logic where many steps share and update the same state. And because filters are deliberately unaware of each other, a failure found at step four can't easily undo step two. If you need coordinated rollback, you probably need a different style.

The full section compares batch and streaming, shows a pipeline in Unix and in Python, and rates the style's characteristics. Next, in Section five point five, we look at microkernel and plug-in architectures. See you there.

What you need to know

Definition and origin

In the pipes and filters style, processing is a chain of independent steps. Each filter reads data from its input, does one job with it and writes the result to its output. Each pipe carries data one way from one filter to the next, usually with a buffer in between. Garlan and Shaw's description (1994) states the constraints that give the style its properties: filters must be independent, must not share state with other filters, and must not know the identity of the filters at either end of their pipes. A filter knows only the format of what it reads and what it writes.

The style's best-known home is Unix. In a memo dated 11 October 1964, Doug McIlroy wrote that there should be ways of coupling programs like a garden hose, screwing in another segment when the data needs to be massaged in another way. Ken Thompson implemented the pipe system call and pipes in the shell in Version 3 Unix in 1973, and the | notation followed in Version 4. The Unix philosophy that grew from it, write programs that do one thing well and work together through a universal text interface, is the pipes-and-filters style applied to a whole operating system's toolset. POSA (1996) catalogued Pipes and Filters as an architectural pattern, and Hohpe and Woolf's Enterprise Integration Patterns (2003) applied it to messaging between systems.

Richards and Ford call the same style the pipeline architecture, and they name four kinds of filter. A producer (or source) starts the flow, reading from a file, a database or a queue. A transformer changes each record: parsing, converting, enriching. A tester checks a condition and passes the record on or drops it, sometimes routing it to one of several outputs. A consumer (or sink) ends the flow, writing to a database, a file or a screen.

Filters and pipes

A pipeline of four filters joined by three one-way pipes: a producer reads order events, a transformer adds restaurant and region, a tester drops test orders, and a consumer writes to the warehouseProducerread ordereventsTransformeradd restaurantand regionTesterdrop testordersConsumerwrite towarehousepipepipepipesourcemapfiltersinkEach filter knows only its input and output format, never its neighboursPipes buffer records and carry them one way
Four kinds of filter connected by pipes; each filter can be developed, tested and replaced on its own.

How it works

The constraints produce the benefits directly. Because a filter shares no state and does not know its neighbours, it can be written and tested alone, with a list of input records and an expected list of outputs; it can be reused in other pipelines; and filters can be added, removed or reordered by changing only how the pipeline is assembled. Because records flow incrementally, a filter can start work on the first record before the previous filter has finished the last, so stages run concurrently and the whole pipeline needs memory for only a few records at a time, not the whole data set.

The pipe hides how data moves. In a Unix shell it is an operating-system buffer between two processes; on Linux it holds 64 KiB by default, and a writer that fills it simply blocks until the reader catches up. In a Python program it can be a generator, so that each filter pulls the next record from the one before. In a data platform it can be a message queue or a topic in a log such as Apache Kafka, which lets filters run on different machines and survive restarts. The filter code barely changes between these; the operational properties change a great deal.

There are variations on the strict form. A pipeline can fan out (a tester routes records to two different consumers) or fan in (two producers feed one transformer). It can be linear, which is easiest to reason about, or a directed acyclic graph of steps, as in workflow tools such as Apache Airflow. Strictly, a filter with internal state, such as one that counts or sorts, breaks the incremental property, because it must see all input before producing output; sort in a Unix pipeline is such a filter. Such filters are allowed but they become the points where memory, latency and restarts need thought.

Examples: where pipelines appear

ETL and data pipelines. Extract, transform, load jobs move data from operational systems into a warehouse: extract yesterday's orders, clean and convert them, join them with restaurant data, load them into reporting tables. Each step is a filter, and modern data platforms (Apache Beam, Spark, dbt models, cloud data-flow services) are largely tools for building and running such pipelines at scale.

Compilers. A classic compiler is a pipeline of phases: lexical analysis turns characters into tokens, parsing turns tokens into a syntax tree, semantic analysis checks types and names, optimisation rewrites an intermediate representation, and code generation emits machine code. Each phase consumes one representation and produces the next, so phases can be developed and replaced separately; the LLVM project, for instance, lets many language front ends share one optimiser and set of back ends. Real compilers relax the strict style, because phases share a symbol table.

Media processing. Video transcoding and audio processing chain decoders, scalers, filters and encoders. GStreamer builds media applications explicitly as pipelines of elements linked through pads, and FFmpeg's filter graphs work the same way.

Log and event processing. Log shippers such as Logstash and Fluent Bit are configured as input, filter and output stages: read log lines, parse them into fields, drop health-check noise, mask personal data, and send them to a search index. The food-delivery platform's observability data flows this way.

Machine-learning feature and inference pipelines. Training data preparation, and the pre-processing and post-processing around a model, are commonly built as pipelines, which Chapter 12 returns to.

# Top ten URLs that returned HTTP 500 in today's access log
grep '" 500 ' access.log \
  | awk '{print $7}' \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -10
A Unix pipeline: six small tools, each a filter, find the URLs that most often failed with HTTP 500 (in the common access-log format the status code follows the quoted request line).
import json

def read_lines(path):                 # producer
    with open(path) as f:
        for line in f:
            yield line

def parse(lines):                     # transformer
    for line in lines:
        yield json.loads(line)

def only_failed(events):              # tester
    for e in events:
        if e["status"] == "payment_failed":
            yield e

def count_by_provider(events):        # consumer
    counts = {}
    for e in events:
        counts[e["provider"]] = counts.get(e["provider"], 0) + 1
    return counts

events = only_failed(parse(read_lines("orders.jsonl")))
print(count_by_provider(events))
The same idea with Python generators: each function is a filter, and records flow one at a time, so the file is never loaded into memory at once.

Batch and streaming

The same structure runs in two modes. Batch processing handles a bounded input, such as all of yesterday's orders, runs to completion and stops. It is simple to reason about: if a run fails, fix the problem and run it again, provided each run replaces its output rather than appending to it. Nightly payouts to restaurants, monthly invoices and warehouse loads are typical batch pipelines. Their weakness is delay: results are as old as the last run.

Stream processing handles an unbounded input, processing each record shortly after it arrives: courier positions, payment events, clicks. Results are fresh, but new questions appear. A stream never ends, so aggregates must be computed over windows (orders per restaurant per five minutes); records can arrive late or out of order; and the pipeline must run continuously, recover from crashes without losing or double-counting records, and cope when input rates spike. Frameworks such as Kafka Streams, Apache Flink and Apache Beam provide windows, checkpoints and state stores for these problems. Beam's model treats batch as a special case of streaming, with a bounded input, so one pipeline definition can run both ways.

Many organisations run both: a stream for fresh, approximate operational dashboards and a batch job that recomputes exact figures overnight. Section 7.8 discusses the data-architecture side of this choice.

Worked example

Worked example: finding the bottleneck

The food-delivery platform loads order events into its warehouse through four filters. Measured on one worker, each filter can process the following number of records per second on its own.

CapacitiesParse 50,000/s; Enrich (looks up restaurant and region) 8,000/s; Validate 40,000/s; Load 12,000/s.
Pipeline throughputA pipeline can go no faster than its slowest filter: min(50,000, 8,000, 40,000, 12,000) = 8,000 records per second.
Time for one day's dataOn a busy day the platform produces 1,000,000 order events. 1,000,000 ÷ 8,000 = 125 seconds.
Scaling the bottleneckEnrich is stateless, so two instances can run in parallel, each taking half the records: 2 × 8,000 = 16,000/s at that stage.
New throughputmin(50,000, 16,000, 40,000, 12,000) = 12,000/s. Load is now the bottleneck. 1,000,000 ÷ 12,000 ≈ 83 seconds, a 33% saving rather than the 50% a naive reading would suggest.
What the buffers doWithout back pressure, Parse would push records into the pipe before Enrich at 50,000/s while Enrich drains 8,000/s, so the buffer would grow by 42,000 records per second until memory ran out.

Speeding up any filter other than the bottleneck changes nothing; measure first.

Only stateless filters can be parallelised this simply. A filter that keeps state, such as running totals per restaurant, must partition its input by key so that each instance sees all records for its keys.

This is the same reasoning as Amdahl's law and utilisation from Section 2.4: the slowest serial stage sets the limit.

Scaling a pipeline moves the bottleneck

A four-stage pipeline whose throughput is limited by its slowest filter: enrichment at 8,000 records per second; running two enrichment instances raises capacity there to 16,000, so the load stage at 12,000 becomes the new bottleneckParse50,000/sEnrich8,000/sValidate40,000/sLoad12,000/sbottleneck: pipeline runs at 8,000/sAfter running two Enrich instances in parallelParse50,000/sEnrich 1: 8,000/sEnrich 2: 8,000/sValidate40,000/sLoad12,000/snew bottleneck: 12,000/s
Throughput is set by the slowest filter; parallelising it helps until another filter becomes the slowest.

Back pressure and error handling

When a producer is faster than a consumer, something has to give. The options are to buffer (which only postpones the problem unless the buffer is bounded), to drop or sample records (acceptable for some metrics, never for payments), or to apply back pressure: make the fast stage slow down to the speed of the slow one. Unix pipes do this automatically because a full pipe blocks the writer. Pull-based designs such as Python generators do it by construction, because nothing is produced until the next stage asks for it. Distributed pipelines need it designed in: bounded queues, consumers that pull at their own pace from a log, or protocols such as the JVM's Reactive Streams specification in which a subscriber signals how many records it is ready for. Section 9.3 covers queues and back pressure in depth.

Errors need a policy for each filter, decided up front. A malformed record (a poison message) should not stop the pipeline: route it to a dead-letter queue or error file with the reason, count it, and alert if the rate rises. A transient failure, such as a lookup service timing out, is retried with back-off. A crash of the pipeline needs a restart point: batch jobs rerun from the start or from a checkpoint, and streaming frameworks checkpoint their position and state. Because restarts mean some records are processed twice, consumers that write results should be idempotent, for example by upserting on a natural key rather than inserting blindly. Errors are where the style is weakest: filters are deliberately unaware of each other, so a failure discovered at step four cannot easily undo what step two already did, and a pipeline that needs that kind of coordinated rollback is probably the wrong style.

Characteristics of pipes and filters (single deployment)

CharacteristicRatingReason
SimplicityHighEach filter does one thing; the flow is easy to draw, read and explain.
Modularity and reuseHighFilters share no state and know only data formats, so they can be reused, reordered and replaced.
TestabilityHighA filter is tested with sample input and expected output, without its neighbours.
Overall costHigh (cheap)One program or job; little infrastructure unless distributed.
ThroughputMedium to highStages run concurrently and stateless stages parallelise, but the slowest filter caps everything.
Latency and interactivityLowBuilt for flows of data, not for request-response conversations with users; each stage adds delay.
DeployabilityLow to mediumAs one program or job, any change redeploys the whole pipeline; filters distributed over queues can be released separately.
Fault toleranceLow to mediumIn one process a crash stops the whole pipeline; checkpoints, retries and dead-letter queues must be added.
Shared state and transactionsLowFilters are deliberately stateless and independent; cross-step consistency or rollback is awkward.
ScalabilityLow to medium in one processHigher when filters are distributed over queues, at the cost of operations and delivery guarantees.

When to use it, when not to, and anti-patterns

Use pipes and filters when the problem is naturally a one-way flow of data through distinct transformations: ingestion, conversion, enrichment, validation, aggregation, export. It is the default for data engineering, for processing files and media, and for any job where the steps change more often than the overall flow. It also appears inside other styles: a nightly reporting job within a modular monolith, or a stream-processing service within an event-driven system.

Avoid it as the main structure of interactive applications, where users expect immediate, conversational responses and the work is not a flow; for logic that needs many steps to share and update the same state; and for processes that need coordinated rollback across steps, which call for a transaction or a saga.

Common anti-patterns: the god filter, one step that parses, enriches, validates and writes, which loses the benefits of the style; hidden shared state, filters that communicate through a shared database table or global variable instead of the pipe, which reintroduces the coupling the style removes; format drift, a producer that silently changes its output format and breaks every downstream filter, prevented by explicit schemas and schema checks (see Section 7.7); unbounded buffers, which turn a slow consumer into an out-of-memory crash; and non-idempotent sinks, which double-count after every retry.

Enforcing the style in code

The style's constraints can be made explicit. Give every filter the same simple signature (an iterable of records in, an iterable out), so that composition is mechanical. Define the record format at each pipe as a schema (a dataclass, a JSON Schema, an Avro or Protocol Buffers definition) and validate it at the boundary. Keep filters free of shared mutable state: an architecture test can forbid filter modules from importing the database layer or each other, so that the only way data passes between them is the pipe. Test each filter with small input and output fixtures, and test the assembled pipeline end to end with a representative sample, including malformed records, to prove the error policy works.

In distributed pipelines, monitor per-stage throughput, queue depth and consumer lag: a growing queue in front of one stage is the visible sign of a bottleneck or a failing filter, and it is the first metric to alert on.

Check your understanding

What three constraints did Garlan and Shaw place on filters?

Filters must be independent entities, must not share state with other filters, and must not know the identity of the filters upstream or downstream of them.

Why does the Unix command sort break the incremental property of a pipeline?

It must read all of its input before it can output the first line, so it holds the whole data set and delays everything after it until its input ends.

A pipeline has stages that can process 20,000, 5,000 and 15,000 records per second. What is its throughput, and what happens if the middle stage is doubled?

5,000 per second, set by the slowest stage. Doubling it to 10,000 makes the pipeline run at 10,000 per second, still below the other two stages.

Why should the final consumer of a restartable pipeline be idempotent?

Because a restart or retry can deliver some records twice; an idempotent write such as an upsert on a natural key gives the same result however many times a record arrives.

Next · 5.5MicrokernelHow a small core system plus independent plug-ins delivers extensibility: registries, contracts, isolation and versioning, real examples from IDEs and browsers, and a worked plug-in contract for country-specific tax rules.Continue →

Sources and further reading

Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.

Ask: “Our nightly ETL job takes four hours and we want it under one. Where should we start looking?”