What is a Data Pipeline? Anatomy and Design Principles
The anatomy of every pipeline, the design principles that make them reliable, and the patterns that separate good from fragile.
What a Data Pipeline Actually Is
The term “data pipeline” gets used loosely — sometimes to mean a single Python script, sometimes to mean an entire data platform, sometimes to mean a Kafka stream. Before building pipelines professionally, you need a precise mental model of what a pipeline is, what it consists of, and what distinguishes a well-designed pipeline from a fragile one.
A data pipeline is a system that moves data from one or more sources to one or more destinations, performing transformations along the way. That definition has three parts: sources (where data originates), transformations (operations applied to data in transit), and sinks (where data lands). Everything else — orchestration, monitoring, error handling, retries — exists to make this movement reliable, repeatable, and observable.
A pipeline is not defined by its technology. A 50-line Python script that reads from a PostgreSQL table and writes to S3 is a pipeline. A Spark job processing 10 TB of Kafka events is a pipeline. A dbt model that transforms Silver tables into a Gold aggregate is a pipeline. What makes all of them pipelines is the same structure: source → extract → transform → load → sink, with orchestration and monitoring around it.
Each Layer of the Pipeline — In Depth
Sources — where data comes from
Every pipeline starts with a source. The source determines what extraction approach is possible, what change detection mechanism is available, and what data quality guarantees you can rely on.
SOURCE TYPE EXAMPLES EXTRACTION APPROACH
Relational DB PostgreSQL, MySQL, Oracle CDC (Debezium) or SQL incremental
Document DB MongoDB, Firestore Change Streams or scheduled export
REST API Stripe, Salesforce HTTP pagination with cursor
Event Stream Kafka, Kinesis, Pub/Sub Kafka Consumer Group (streaming)
File Drop SFTP, S3 landing zone File event trigger or scheduled scan
Webhook Payment events, IoT HTTP endpoint + Kafka/DB write
WHAT TO UNDERSTAND ABOUT EACH SOURCE:
Schema, cardinality, change rate, latency need, quality, access, and
history — before designing any pipeline, know all seven for its source.Extraction — full vs incremental
The two fundamental extraction patterns are full extraction (read everything every time) and incremental extraction (read only what changed since the last run). The choice has enormous consequences for pipeline performance and source system load.
SELECT * FROM orders; -- every row, every time
# Use for: small tables (<1M rows), reference/dimension tables,
# tables with no reliable "changed at" timestamp
# Avoid for: large transaction tables, high-velocity sources,
# sources with rate limits or shared connection poolsimport json
from pathlib import Path
from datetime import datetime, timezone
CHECKPOINT = Path('/data/checkpoints/orders.json')
def load_checkpoint() -> datetime:
if CHECKPOINT.exists():
return datetime.fromisoformat(json.loads(CHECKPOINT.read_text())['last_updated_at'])
return datetime(2020, 1, 1, tzinfo=timezone.utc)
def save_checkpoint(ts: datetime) -> None:
CHECKPOINT.write_text(json.dumps({'last_updated_at': ts.isoformat()}))
last_run, current_run = load_checkpoint(), datetime.now(timezone.utc)
rows = db.query("SELECT * FROM orders WHERE updated_at > %s AND updated_at <= %s",
(last_run, current_run))
write_to_destination(rows)
save_checkpoint(current_run) # advance ONLY after a successful write# four pitfalls this pattern has to account for:
1. Late-arriving data past the window → overlap by 30 min, upsert at destination
2. Deletes are invisible to incremental SQL → use CDC (Module 24)
3. Clock skew between pipeline and source → use the source DB's own NOW()
4. No updated_at column at all → use max(id) watermark, or full extractTransformation — the heart of the pipeline
Every transformation in a pipeline is a business decision encoded in code — and every transformation is a potential source of bugs.
TYPE EXAMPLE
Type casting "380.00" → DECIMAL
Null handling COALESCE(amount, 0)
Deduplication ROW_NUMBER() OVER (PARTITION BY id)
Filtering WHERE status != 'test'
Normalisation LOWER(status), TRIM(name)
Enrichment JOIN to customers table
Aggregation SUM, COUNT, AVG, PERCENTILE
Anonymisation SHA256(email)
Window calc SUM OVER (PARTITION BY ... ORDER BY ...)
WHERE IT HAPPENS: Python/Pandas (general-purpose, easy to test) ·
SQL/dbt (set-based, best for tabular data) · Spark (distributed, complex) ·
Flink/Spark Streaming (real-time)Loading — full replace, append, and upsert
TRUNCATE TABLE silver.store_master;
INSERT INTO silver.store_master SELECT * FROM source_store_master;
-- Fix the empty-window risk with a staging table + atomic rename swap:
CREATE TABLE silver.store_master_staging AS SELECT * FROM source_store_master;
ALTER TABLE silver.store_master RENAME TO store_master_old;
ALTER TABLE silver.store_master_staging RENAME TO store_master;
DROP TABLE silver.store_master_old;INSERT INTO silver.events (event_id, user_id, event_type, ts)
SELECT event_id, user_id, event_type, ts FROM staging.events
WHERE ts > (SELECT MAX(ts) FROM silver.events);
-- add a UNIQUE constraint on event_id + ON CONFLICT DO NOTHING to survive rerunsINSERT INTO silver.orders (order_id, status, amount, updated_at)
VALUES (%s, %s, %s, %s)
ON CONFLICT (order_id) DO UPDATE SET
status = EXCLUDED.status, amount = EXCLUDED.amount, updated_at = EXCLUDED.updated_at
WHERE silver.orders.updated_at < EXCLUDED.updated_at;
-- the WHERE clause stops a replayed OLD record from overwriting a newer one
-- Snowflake MERGE — same idea:
MERGE INTO silver.orders AS target USING staging.orders AS source
ON target.order_id = source.order_id
WHEN MATCHED AND target.updated_at < source.updated_at THEN UPDATE SET status = source.status
WHEN NOT MATCHED THEN INSERT (order_id, status, amount, updated_at)
VALUES (source.order_id, source.status, source.amount, source.updated_at);-- a replayed record with an OLDER updated_at than what's already in the table:
UPDATE 0
-- the WHERE clause silently skipped it — exactly the intended, idempotent behaviorThe Eight Design Principles of Reliable Pipelines
Two pipelines can be functionally identical — they move the same data from the same source to the same destination — but have dramatically different reliability profiles. One fails once a month and recovers automatically in 15 minutes. The other fails weekly, requires manual intervention, and sometimes produces wrong data.
The difference is design principles. These eight are what senior data engineers apply when designing pipelines and what they look for when reviewing pipeline code.
Pipeline Topologies — The Shapes Data Flows Take
Real data platforms are not single linear pipelines. They are networks of pipelines with different shapes. Recognising the topology of a data flow immediately tells you its failure modes, its parallelism opportunities, and its monitoring requirements.
Linear, fan-out, and fan-in
LINEAR — one input, one output, sequential stages
[PostgreSQL orders] → [Python cleaner] → [S3 Bronze Parquet]
Simple failure model. No parallelism between stages.
FAN-OUT — one source, multiple sinks (may partially fail)
┌→ [S3 data lake (Parquet)]
[Kafka payments] ─────┤→ [PostgreSQL (OLTP write-through)]
└→ [Elasticsearch (search index)]
Must decide: fail all if any fail, or allow partial success?
FAN-IN — multiple sources merged into one sink
[Stripe payments] ─┐
[Square payments] ─┤→ [UNION ALL] → [silver.all_payments]
[Venmo payments] ─┘
Must dedup after union — same transaction ID from multiple sources?DAGs, streaming, and Lambda architecture
DAG — stages with dependencies, some run in parallel (no cycles)
[Extract orders] ─────┬──────────────────┐
[Extract customers] ──┤→ [Silver orders] →┤→ [Gold daily revenue]
[Extract restaurants] ─┘ └→ [Gold customer LTV]
A failed upstream stage blocks all downstream stages — this is what Airflow models.
STREAMING — continuous, event-driven, no concept of "a run"
[Kafka: orders] → [Flink/Spark Streaming] → [Kafka: enriched_orders]
→ [Cassandra (real-time store)]
Failure means falling behind (consumer lag), not stopping completely.
LAMBDA — batch path for accuracy + streaming path for low latency
[Source] ──┬─ [Batch, nightly] ─────→ [Batch layer (accurate)]
└─ [Streaming, real-time] → [Speed layer (fast)] → [Serving: merge both]
Two codebases for the same logic — Kappa (streaming-only) is the modern alternative.ETL vs ELT vs EL — Why the Order Matters
The three acronyms describe where transformation happens in the pipeline — not a trivial naming distinction. The position of the transformation step determines what tools you use, who can see and change the logic, and how you debug when data is wrong.
| Pattern | Full name | Where transform happens | When to use |
|---|---|---|---|
| ETL | Extract → Transform → Load | Before loading — a Python/Spark pipeline does the transformation. | Sensitive source data (PII masking before landing), strict destination schema, transformation needs Python/ML. |
| ELT | Extract → Load → Transform | After loading — raw data lands first, THEN SQL/dbt transforms it in place. | A modern warehouse (Snowflake/BigQuery) is the compute engine. Logic is primarily SQL. Analysts need raw data access. |
| EL | Extract → Load (no transform) | No transformation — raw data lands exactly as received. | Landing zone / Bronze ingestion. Preserve the exact original data for audit, debugging, or reprocessing. |
def etl_orders(source_conn, dest_conn):
raw = pd.read_sql("SELECT * FROM orders WHERE updated_at > %s", source_conn)
raw = raw.drop_duplicates(subset=['order_id'])
raw = raw[raw['amount'] > 0]
raw['status'] = raw['status'].str.lower().str.strip()
raw['customer_city'] = raw['customer'].apply(lambda x: x.get('city')) # flatten JSON
raw.to_sql('silver_orders', dest_conn, if_exists='append', index=False)# Step 1: EL — load raw data as-is
def extract_load_orders(source_conn, warehouse_conn):
raw = pd.read_sql("SELECT * FROM orders WHERE updated_at > %s", source_conn)
raw.to_sql('raw_orders', warehouse_conn, if_exists='append')
# Step 2: models/silver/orders.sql — dbt transforms the raw table in place
# SELECT order_id, amount::DECIMAL(10,2), LOWER(TRIM(status)) AS status
# FROM {{ source('raw', 'orders') }}
# WHERE amount > 0 AND LOWER(status) IN ('placed','confirmed','delivered','cancelled')
# QUALIFY ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) = 1
# 2026 default: EL raw into Bronze, dbt transforms Silver/Gold.
# Python ETL only for PII masking, ML features, and complex flattening.How Pipelines Fail — The Complete Taxonomy
Every pipeline will fail. The question is not whether but when and how badly. Understanding the complete taxonomy of pipeline failures is what lets a data engineer design pipelines that fail gracefully, recover automatically, and alert clearly when human intervention is needed.
| Category | Example | Default (bad) behavior | Correct behavior |
|---|---|---|---|
| Source unavailable | DB timeout, API 503, SFTP unreachable | Crash with error | Retry with backoff, alert if > N retries |
| Source data changed | New/renamed column, type change | Wrong data written silently | Schema validation, alert + DLQ |
| Source data quality | NULL in required field, duplicate PKs | Wrong aggregations (silent!) | Row-level validation, DLQ invalid rows |
| Transformation bug | Wrong SQL logic, off-by-one date range | Wrong data, no error | dbt tests before deploy, code review |
| Resource exhaustion | OOM, disk full, API rate limit | Crash or corrupt output | Chunked processing, proactive throttling |
| Infrastructure | Network partition, pod eviction | Timeout, mid-run failure | Backoff retry, resumable from checkpoint |
| Orchestration | Dependency failed, timezone bug | Downstream skipped silently | Explicit failure propagation, fixed UTC schedule |
| SLA breach | Pipeline takes 4h instead of 1h | Late data in dashboards | Timeout + SLA monitoring, not just failure alerts |
Metrics every pipeline should record on every run
CREATE TABLE monitoring.pipeline_runs (
run_id UUID PRIMARY KEY, pipeline_name VARCHAR(100) NOT NULL, run_date DATE NOT NULL,
started_at TIMESTAMPTZ NOT NULL, finished_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL, -- 'running', 'success', 'failed', 'partial'
rows_extracted BIGINT, rows_written BIGINT, rows_rejected BIGINT,
duration_seconds DECIMAL(10,2), error_message TEXT, dlq_count INTEGER DEFAULT 0
);
-- day-over-day row count check, run after every load:
SELECT run_date, rows_written, LAG(rows_written) OVER (ORDER BY run_date) prev_day_rows,
ABS(rows_written - LAG(rows_written) OVER (ORDER BY run_date))
/ NULLIF(LAG(rows_written) OVER (ORDER BY run_date), 0) pct_change
FROM monitoring.pipeline_runs WHERE pipeline_name = 'orders_ingestion'
ORDER BY run_date DESC LIMIT 30;ALERT CONDITIONS:
status = 'failed' → immediate alert
duration_seconds > expected * 2 → SLA warning
rows_written < expected * 0.8 → data quality alert
rows_rejected > total_rows * 0.05 → data quality alert
No row inserted for today by 8 AM → pipeline did not run at allPipeline vs Workflow vs DAG vs Job — Precise Terminology
These terms are often used interchangeably but have distinct meanings in professional data engineering. Using them precisely in conversations, documentation, and code makes communication clearer.
| Term | Precise meaning | Example |
|---|---|---|
| Task | The smallest unit of work — one atomic operation that succeeds or fails as a whole. | Run dbt model fct_orders. Extract one day of orders from API. |
| Job | A single executable unit — a script, a Spark application, a dbt model run. | orders_ingestion.py — a Python script that runs once and exits. |
| Pipeline | A sequence of tasks or jobs that move data from source to sink. | Extract orders → Bronze Parquet → Silver cleaning → Gold aggregation. |
| Workflow | A coordinated set of pipelines with dependencies, schedules, and error handling. | The daily FreshCart workflow: ingest orders + customers + products, then Silver, then Gold. |
| DAG | Directed Acyclic Graph — the graph representation of a workflow, used in Airflow. | An Airflow DAG with 12 tasks: 3 extraction → 2 validation → 4 dbt → 3 alert. |
| Orchestrator | The system that schedules and executes workflows. | Apache Airflow, Prefect, Dagster, dbt Cloud, GitHub Actions. |
What Good Pipeline Code Looks Like
A pipeline that is correct but unreadable, untestable, and unmaintainable is a liability. Production pipelines run for years, and the person who wrote a particular branch condition three years ago is rarely around to explain it. Good pipeline code is self-documenting, testable at every layer, and structured so changes can be made safely.
Setup — imports, constants, and validated config
"""
Daily orders ingestion: PostgreSQL source → S3 Bronze Parquet
Schedule: 00:30 UTC daily, previous day. Owner: data-team@freshcart.com
Idempotent: yes (upserts on order_id). Resumable: yes (checkpoint per file).
"""
import os, json, logging, uuid
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Iterator
import psycopg2
import pyarrow as pa
import pyarrow.parquet as pq
BATCH_SIZE = 100_000
CHECKPOINT_DIR = Path('/data/checkpoints')
DLQ_DIR = Path('/data/dlq')
class Config:
db_url: str = os.environ['SOURCE_DB_URL']
s3_path: str = os.environ['S3_OUTPUT_PATH']Extraction and validation — small, single-purpose functions
def extract_orders(conn, run_date: date) -> Iterator[dict]:
"""Extract all orders for run_date. Fixed window — idempotent for the same date."""
start_ts = datetime(run_date.year, run_date.month, run_date.day, tzinfo=timezone.utc)
end_ts = start_ts + timedelta(days=1)
with conn.cursor('orders_cursor') as cur: # server-side cursor: streams rows
cur.execute("SELECT * FROM orders WHERE created_at >= %s AND created_at < %s",
(start_ts, end_ts))
for row in cur:
yield dict(zip([d[0] for d in cur.description], row))
def validate_row(row: dict) -> tuple[dict | None, str | None]:
"""Pure function — no I/O, fully unit-testable."""
if not row.get('order_id'):
return None, 'missing_order_id'
if (row.get('amount') or 0) <= 0:
return None, f'invalid_amount: {row.get("amount")}'
if row.get('status') not in ('placed', 'confirmed', 'delivered', 'cancelled'):
return None, f'invalid_status: {row.get("status")}'
return row, None
def write_parquet_batch(rows: list[dict], path: str) -> None:
pq.write_table(pa.Table.from_pylist(rows), path, compression='zstd')Orchestration — wiring extract, validate, and load together
def run(run_date: date) -> dict:
run_id, log = str(uuid.uuid4()), logging.getLogger('orders_ingestion')
stats = {'run_id': run_id, 'rows_extracted': 0, 'rows_written': 0, 'rows_rejected': 0}
log.info('Pipeline started', extra={'run_date': str(run_date), 'run_id': run_id})
conn, batch, chunk = psycopg2.connect(Config.db_url), [], 0
try:
for row in extract_orders(conn, run_date):
stats['rows_extracted'] += 1
clean, error = validate_row(row)
if error:
stats['rows_rejected'] += 1
with open(DLQ_DIR / f'orders_{run_date}_{run_id}.ndjson', 'a') as f:
f.write(json.dumps({'error': error, 'row': row}) + '\n')
continue
batch.append(clean)
if len(batch) >= BATCH_SIZE:
chunk += 1
write_parquet_batch(batch, f'{Config.s3_path}/date={run_date}/part-{chunk:05d}.parquet')
stats['rows_written'] += len(batch)
batch = []
if batch:
chunk += 1
write_parquet_batch(batch, f'{Config.s3_path}/date={run_date}/part-{chunk:05d}.parquet')
stats['rows_written'] += len(batch)
finally:
conn.close()
log.info('Pipeline complete', extra=stats)
return stats
if __name__ == '__main__':
import sys
logging.basicConfig(level=logging.INFO, format='%(message)s')
run_date = date.fromisoformat(sys.argv[1]) if len(sys.argv) > 1 else date.today() - timedelta(days=1)
result = run(run_date)
sys.exit(0 if result['rows_rejected'] / max(result['rows_extracted'], 1) < 0.05 else 1){"run_date": "2026-03-17", "run_id": "a1f9-...", "msg": "Pipeline started"}
...
{"rows_extracted": 812400, "rows_written": 811980, "rows_rejected": 420, "msg": "Pipeline complete"}
$ echo $?
0 # 420/812400 = 0.05% rejection — well under the 5% exit-code thresholdFive Misconceptions About Data Pipelines
Auditing a Fragile Pipeline and Redesigning It
You are asked to audit the existing orders pipeline and identify what is fragile about it. Here is the original pipeline code you inherit:
# ORIGINAL PIPELINE (from a junior engineer two years ago)
import psycopg2
import pandas as pd
conn = psycopg2.connect("postgresql://admin:password123@prod-db-01:5432/orders")
df = pd.read_sql("SELECT * FROM orders", conn) # PROBLEM 1
df['amount'] = df['amount'].astype(float) # PROBLEM 2
df = df.dropna() # PROBLEM 3
df.to_sql('silver_orders', warehouse_conn, if_exists='replace') # PROBLEM 4
print("done") # PROBLEM 5Problem 1 — Full extraction every run: reads all 180 million rows every morning, taking 4 hours and slowing production. No incremental pattern (violates Source Isolation).
Problem 2 — Silent type casting failure: astype(float) crashes the entire pipeline the moment one vendor sends a non-numeric amount, which happens weekly (violates Data Quality Enforcement).
Problem 3 — Silent data deletion: dropna() drops every row with any null — orders missing a promo_code (the majority) vanish, and revenue metrics are quietly wrong.
Problem 4 — Truncate-and-replace every run: if_exists='replace' drops and recreates the table every run — empty for the whole 4-hour window (violates Idempotency and Atomicity).
Problem 5 — No observability: the only output is “done” — no row counts, no timing, no run ID to debug with.
After applying the eight design principles, the pipeline becomes the structured, resumable, observable version shown in Part 08. It processes only yesterday’s new orders (incremental), validates each row and routes failures to a DLQ (data quality enforcement), writes in batches with upserts (idempotency), logs structured metrics (observability), and takes 4 minutes instead of 4 hours (source isolation). Every principle has a direct, measurable impact.
5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓A data pipeline moves data from sources to sinks through transformations. Every pipeline has the same anatomy: Source → Extraction → Transformation → Loading → Sink, with Orchestration and Monitoring around it. The technology changes; the anatomy does not.
- ✓Extraction is either full (read everything, every run — simple, expensive) or incremental (read only changes since last run — efficient, requires a watermark column and checkpoint). Use incremental extraction for any table with more than a few million rows.
- ✓Loading patterns: full replace (truncate + reload — simple, destination empty during run), append-only (INSERT for immutable events), upsert (INSERT ... ON CONFLICT DO UPDATE — the correct default for mutable entities). Always use upserts with a UNIQUE constraint on the business key.
- ✓ETL transforms before loading — good for PII masking, complex Python logic. ELT loads raw then transforms with SQL/dbt inside the warehouse — the modern standard. Most teams in 2026 use ELT with dbt for transformations and raw data preserved in the landing zone.
- ✓The eight design principles: Idempotency, Resumability, Observability, Isolation, Data Quality Enforcement, Source Isolation, Atomicity at the right granularity, Minimal Footprint. Apply all eight and pipelines become reliable infrastructure. Ignore them and they become fragile scripts.
- ✓Idempotency is the most critical single principle. Achieved by: upserts not inserts, UNIQUE constraints on business keys, fixed time windows as parameters. An idempotent pipeline can be rerun at any time without causing data quality issues.
- ✓The most dangerous pipeline failure is silent data incorrectness — the pipeline reports success but the data is wrong. Prevent it with row count validation after every run, value range checks in dbt tests, and comparing output row counts to source row counts.
- ✓Pipeline topologies: linear (one source, one sink), fan-out (one source, multiple sinks), fan-in (multiple sources, one sink), DAG (multiple stages with dependencies). Each topology has different failure modes and parallelism opportunities.
- ✓Write pipeline runs metadata to a monitoring table: run_id, pipeline_name, started_at, finished_at, status, rows_extracted, rows_written, rows_rejected, duration_seconds. Alert on failures, SLA breaches, and anomalous row counts — not just outright failures.
- ✓A pipeline and a DAG are not the same thing. A pipeline is a data flow. A DAG is the dependency graph that orchestrates multiple pipelines or tasks. An Airflow DAG for the morning data platform may contain 15 tasks across 6 pipelines.
What comes next
Module 21 covers the three processing models — batch, streaming, and micro-batch — with real latency and throughput numbers so you can match the right model to any business requirement.
Module 21 → Batch vs Streaming vs Micro-BatchDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.