Pipes and filters
Pipes and filters turns processing into a line of small, independent steps. This page builds Pedal's monthly city report from six filters, shows why every clean-up must come before the count, and lets you rearrange the pipeline to see which mistakes it catches and which it lets through.
1. In one picture
Pipes and filters splits processing into a chain of independent steps. Each filter reads input, does one transformation and writes output; pipes carry the data from one filter to the next. Unix shell commands (grep | sort | uniq -c) are the classic example; modern versions are data pipelines, stream processors, compilers and image processing chains.
- Filters are independent: each can be tested alone, reused in other pipelines, and run on its own machine.
- The contract is the data shape between steps: each filter states what it accepts and what it produces.
- Pipes can be in memory, files, or a queue or stream between processes, which lets slow steps run as several copies.
2. Pedal built this way
Every month the city wants rides and minutes per district, with test rides removed, abandoned bikes capped at 12 hours, and no rider names. Follow the four records through the chain.
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| Each filter does one transformation | Steps are easy to test, reuse and replace. | A "process_everything" step that cleans, joins and formats. |
| Filters share no state | Any step can be moved, copied or run elsewhere. | Two filters coordinate through a global counter. |
| The data shape between steps is a contract | A step can be swapped for any other that honours it. | A step quietly renames a field the next one needs. |
| Order is explicit and meaningful | Results depend on order; it must be visible in one place. | Test rides are counted because filtering happened after aggregation. |
4. How it goes wrong
5. The code
Six filters, each a small generator over a stream of records: drop test rides, cap abandoned rides at 12 hours, add the district, remove names, count per district, format as CSV. The pipeline function just feeds each filter's output to the next.
from __future__ import annotations
from typing import Callable, Iterable, Iterator
Record = dict
Filter = Callable[[Iterable[Record]], Iterator[Record]]
DISTRICT = {"D12": "Centre", "D40": "Harbour"}
# Each filter does one job, reads a stream and writes a stream. None knows about the others.
def drop_test_rides(rows: Iterable[Record]) -> Iterator[Record]:
return (r for r in rows if not r["test"])
def cap_minutes(rows: Iterable[Record]) -> Iterator[Record]:
return ({**r, "minutes": min(r["minutes"], 720)} for r in rows) # forgotten bikes count as 12 hours
def add_district(rows: Iterable[Record]) -> Iterator[Record]:
return ({**r, "district": DISTRICT[r["dock"]]} for r in rows)
def anonymise(rows: Iterable[Record]) -> Iterator[Record]:
return ({k: v for k, v in r.items() if k != "rider"} for r in rows)
def count_by_district(rows: Iterable[Record]) -> Iterator[Record]:
totals: dict[str, list[int]] = {}
for r in rows:
t = totals.setdefault(r["district"], [0, 0])
t[0] += 1
t[1] += r["minutes"]
return ({"district": d, "rides": n, "minutes": m} for d, (n, m) in sorted(totals.items()))
def to_csv(rows: Iterable[Record]) -> Iterator[Record]:
yield {"line": "district,rides,minutes"}
for r in rows:
yield {"line": f"{r['district']},{r['rides']},{r['minutes']}"}
def pipeline(source: Iterable[Record], *filters: Filter) -> Iterator[Record]:
stream: Iterable[Record] = source
for f in filters: # the pipe: each filter's output feeds the next
stream = f(stream)
return iter(stream)
if __name__ == "__main__":
rides = [{"id": "R1", "rider": "Mia", "minutes": 42, "dock": "D12", "test": False},
{"id": "R2", "rider": "Leo", "minutes": 15, "dock": "D12", "test": False},
{"id": "R3", "rider": "qa-bot", "minutes": 5, "dock": "D12", "test": True},
{"id": "R4", "rider": "Ana", "minutes": 900, "dock": "D40", "test": False}]
steps = [drop_test_rides, cap_minutes, add_district, anonymise, count_by_district, to_csv]
for row in pipeline(rides, *steps):
print(row["line"])
print("pipeline:", " | ".join(f.__name__ for f in steps))
type Rec = Record<string, string | number | boolean>;
type Filter = (rows: Iterable<Rec>) => Iterable<Rec>;
const DISTRICT: Record<string, string> = { D12: "Centre", D40: "Harbour" };
// Each filter does one job, reads a stream and writes a stream. None knows about the others.
function* dropTestRides(rows: Iterable<Rec>): Iterable<Rec> { for (const r of rows) if (!r.test) yield r; }
function* capMinutes(rows: Iterable<Rec>): Iterable<Rec> { for (const r of rows) yield { ...r, minutes: Math.min(r.minutes as number, 720) }; } // forgotten bikes count as 12 hours
function* addDistrict(rows: Iterable<Rec>): Iterable<Rec> { for (const r of rows) yield { ...r, district: DISTRICT[r.dock as string] }; }
function* anonymise(rows: Iterable<Rec>): Iterable<Rec> { for (const r of rows) { const { rider: _drop, ...rest } = r; void _drop; yield rest; } }
function* countByDistrict(rows: Iterable<Rec>): Iterable<Rec> {
const totals = new Map<string, [number, number]>();
for (const r of rows) { const t = totals.get(r.district as string) ?? [0, 0]; t[0] += 1; t[1] += r.minutes as number; totals.set(r.district as string, t); }
for (const d of [...totals.keys()].sort()) { const [n, m] = totals.get(d) as [number, number]; yield { district: d, rides: n, minutes: m }; }
}
function* toCsv(rows: Iterable<Rec>): Iterable<Rec> {
yield { line: "district,rides,minutes" };
for (const r of rows) yield { line: `${r.district},${r.rides},${r.minutes}` };
}
// The pipe: each filter's output feeds the next.
const pipeline = (source: Iterable<Rec>, ...filters: [string, Filter][]): Iterable<Rec> => filters.reduce((s, [, f]) => f(s), source);
const rides: Rec[] = [
{ id: "R1", rider: "Mia", minutes: 42, dock: "D12", test: false },
{ id: "R2", rider: "Leo", minutes: 15, dock: "D12", test: false },
{ id: "R3", rider: "qa-bot", minutes: 5, dock: "D12", test: true },
{ id: "R4", rider: "Ana", minutes: 900, dock: "D40", test: false },
];
const steps: [string, Filter][] = [["drop_test_rides", dropTestRides], ["cap_minutes", capMinutes], ["add_district", addDistrict],
["anonymise", anonymise], ["count_by_district", countByDistrict], ["to_csv", toCsv]];
for (const row of pipeline(rides, ...steps)) console.log(row.line);
console.log("pipeline: " + steps.map(([n]) => n).join(" | "));import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Main {
static final Map<String, String> DISTRICT = Map.of("D12", "Centre", "D40", "Harbour");
record Step(String name, Function<Stream<Map<String, Object>>, Stream<Map<String, Object>>> f) {}
static Map<String, Object> with(Map<String, Object> r, String k, Object v) { Map<String, Object> m = new HashMap<>(r); m.put(k, v); return m; }
// Each filter does one job, reads a stream and writes a stream. None knows about the others.
static Stream<Map<String, Object>> dropTestRides(Stream<Map<String, Object>> rows) { return rows.filter(r -> !(Boolean) r.get("test")); }
static Stream<Map<String, Object>> capMinutes(Stream<Map<String, Object>> rows) { return rows.map(r -> with(r, "minutes", Math.min((Integer) r.get("minutes"), 720))); } // forgotten bikes count as 12 hours
static Stream<Map<String, Object>> addDistrict(Stream<Map<String, Object>> rows) { return rows.map(r -> with(r, "district", DISTRICT.get((String) r.get("dock")))); }
static Stream<Map<String, Object>> anonymise(Stream<Map<String, Object>> rows) { return rows.map(r -> { Map<String, Object> m = new HashMap<>(r); m.remove("rider"); return m; }); }
static Stream<Map<String, Object>> countByDistrict(Stream<Map<String, Object>> rows) {
Map<String, int[]> totals = new TreeMap<>();
rows.forEach(r -> { int[] t = totals.computeIfAbsent((String) r.get("district"), k -> new int[2]); t[0]++; t[1] += (Integer) r.get("minutes"); });
List<Map<String, Object>> out = new ArrayList<>();
totals.forEach((d, t) -> out.add(Map.of("district", d, "rides", t[0], "minutes", t[1])));
return out.stream();
}
static Stream<Map<String, Object>> toCsv(Stream<Map<String, Object>> rows) {
return Stream.concat(Stream.of(Map.<String, Object>of("line", "district,rides,minutes")),
rows.map(r -> Map.<String, Object>of("line", r.get("district") + "," + r.get("rides") + "," + r.get("minutes"))));
}
// The pipe: each filter's output feeds the next.
static Stream<Map<String, Object>> pipeline(Stream<Map<String, Object>> source, List<Step> steps) {
Stream<Map<String, Object>> s = source;
for (Step st : steps) s = st.f().apply(s);
return s;
}
static Map<String, Object> ride(String id, String rider, int minutes, String dock, boolean test) {
Map<String, Object> m = new HashMap<>();
m.put("id", id); m.put("rider", rider); m.put("minutes", minutes); m.put("dock", dock); m.put("test", test);
return m;
}
public static void main(String[] args) {
List<Map<String, Object>> rides = List.of(ride("R1", "Mia", 42, "D12", false), ride("R2", "Leo", 15, "D12", false),
ride("R3", "qa-bot", 5, "D12", true), ride("R4", "Ana", 900, "D40", false));
List<Step> steps = List.of(new Step("drop_test_rides", Main::dropTestRides), new Step("cap_minutes", Main::capMinutes), new Step("add_district", Main::addDistrict),
new Step("anonymise", Main::anonymise), new Step("count_by_district", Main::countByDistrict), new Step("to_csv", Main::toCsv));
pipeline(rides.stream(), steps).forEach(r -> System.out.println(r.get("line")));
System.out.println("pipeline: " + steps.stream().map(Step::name).collect(Collectors.joining(" | ")));
}
}Output (the same in all three languages)
district,rides,minutes Centre,2,57 Harbour,1,720 pipeline: drop_test_rides | cap_minutes | add_district | anonymise | count_by_district | to_csv
6. Try it
Build the pipeline
Turn steps on and off and move them up and down. Each step checks that it received the shape it expects.
7. What it costs, and when to pick another style
What you get
- Simple, testable steps: each filter is a small function with clear input and output.
- Reuse and recombination: new reports reuse old steps in a new order.
- Parallelism: slow steps run as several copies; steps can stream.
- Easy to extend: a new requirement is often a new filter in the chain.
What you pay
- One direction only: no back-and-forth conversation between steps.
- Serialisation overhead: each pipe may encode and decode data.
- Whole-dataset steps break streaming: sorts and joins hold everything.
- Error handling across steps: partial failures need a plan for retries and bad records.
| If… | Consider instead |
|---|---|
| Parts must react to events independently, not in a fixed line | Event-driven |
| Request and reply with a user waiting | Client-server |
| Huge data, many machines | A distributed dataflow engine (Spark, Flink, Beam): pipes and filters at scale |
| Steps vary per customer or region | Filters as plugins in a microkernel |
8. Signs you've outgrown it
- Steps need to call each other back, or share state.
- The pipeline has grown branches, joins and loops that are hard to read as a line.
- One run takes longer than the interval between runs.
9. Remember it and say it
How this hook works: what “The car wash” means
What the slogan means, phrase by phrase
- “The car wash”
- A fixed sequence of stations.
- “each does one job”
- Filters: small, independent, swappable.
Why this picture fits: A car wash tunnel: pre-soak, brushes, rinse, wax, dry; a new station can be added without rebuilding the tunnel.
The story → the concept
| The car wash | Pedal |
|---|---|
| The car | The month's ride records |
| Stations | Drop tests, cap minutes, add district, anonymise, count, format |
| The conveyor | The pipe between steps |
| Adding a wax station | Adding anonymise without touching the others |
Watch it on an example
- Test rides are dropped before counting.
- Ana's forgotten bike counts as 720 minutes, not 900.
- The CSV has districts and totals, never names.
The trap it saves you from
Aggregating before filtering, so wrong records end up inside totals where no later step can remove them.
It still works when…
Inside other styles: a serverless function chain or a stream processor is often a pipeline.
In an interview or design review
- Name the steps and the data between them: "raw rides → cleaned rides → rides with district → totals per district → CSV."
- Say where it can run in parallel and which steps need the whole dataset.
- Handle bad records: a dead-letter output instead of failing the run.
10. Check yourself
What do two filters in a pipeline share?
Test rides appear in the city's totals. Which ordering mistake is likely?
Which kind of step stops a pipeline from streaming?
When is pipes and filters a poor fit?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture 16a: Pipeline and serverless | How an architect evaluates the style. |
| System design 23: Batch and stream processing | Pipelines at scale: batch jobs and stream processors. |
| Pipeline (Unix) | The original pipes and filters, and their history. |