Moving data with ETL pipelines
Open in the interactive app: practice, quiz, mock interview →In one minute
- An ETL pipeline extracts data from operational sources, transforms it into clean, modelled shapes, and loads it where analysts and systems can query it.
- Modern stacks usually run ELT: land raw data first, then transform inside the warehouse with SQL, because warehouse compute is elastic and raw data stays replayable.
- The core reliability property is idempotent, partitioned loads: rerunning a day must produce the same result, not double it.
- Extraction is the hardest part to get right: full dumps are simple but heavy, incremental cursors miss deletes, and change data capture (CDC) is accurate but operationally demanding.
- Pipelines fail quietly. Data quality checks and freshness SLAs matter as much as job success.
How it works
Extract
There are three common patterns. A full snapshot copies the whole table each run; easy and correct, but a 2 TB table nightly is expensive and hammers the source. An incremental cursor selects rows where updated_at > last_watermark; cheap, but it misses hard deletes and rows whose clock was skewed or updated inside the same transaction window. CDC reads the database's replication log (MySQL binlog, Postgres WAL) and emits every insert, update, and delete in commit order (Debezium docs), which is the only approach that captures deletes and intermediate states reliably.
Transform
Transformations clean types, deduplicate, conform keys across systems, join reference data, and build modelled tables such as facts and dimensions. A good layout has layers: raw (exact copy of the source), staging (typed and deduplicated), and marts (business-level tables). Each layer is a pure function of the one below, so you can rebuild any layer from raw.
Load
Loads should replace a whole partition atomically: write to a temporary location, validate, then swap. INSERT appends are the classic source of double counting when a job retries. For slowly changing dimensions, a MERGE keyed on the business key keeps history rows with valid_from and valid_to.
Orchestration
An orchestrator (Airflow, Dagster, Prefect) runs tasks as a DAG on a schedule, handles retries, and tracks which logical date each run covers. The important idea is the logical date: the run for 2026-10-14 processes exactly that day's partition, whenever it actually executes. That makes backfills a loop over dates, provided tasks are idempotent and read the interval from the run instead of calling now() (Airflow docs).
Down the stack
Layer 1The contract
For every logical date, the published partition reflects every committed source change in that interval exactly once, no matter how many times the job ran. Readers see either the old partition or the new one, never a mix. Data is published by a stated freshness deadline (for example 06:00 UTC), or an alert fires. If checks fail, the previous good version stays visible.
Layer 2The mechanism
In Postgres, CDC uses a logical replication slot with the pgoutput plugin. The slot remembers a confirmed_flush_lsn; the server keeps all WAL after that point. The connector decodes WAL into BEGIN, INSERT, UPDATE, DELETE, and COMMIT messages with the commit LSN, and acknowledges an LSN only after Kafka has durably accepted the events. In MySQL, the connector reads the row-based binlog (WRITE_ROWS, UPDATE_ROWS, DELETE_ROWS events) and tracks a GTID set. The initial load takes a consistent snapshot, records the log position at the snapshot, then streams from that position, so no change is missed between the two (Debezium docs). Until Postgres 17, a logical slot lived only on the primary, so a failover lost the slot and forced a fresh snapshot; Postgres 17 can sync slots created with failover to a standby (sync_replication_slots) (Postgres docs), and Debezium exposes this as slot.failover. In Airflow, each run carries data_interval_start and data_interval_end; tasks template these into queries, and a backfill simply creates runs for past intervals. Airflow 3.0 (April 2025) changed the details: execution_date is gone in favour of logical_date, manual and asset-triggered runs can have no logical date at all, catchup now defaults to off, and a bare cron string now uses a timetable with no data interval unless create_cron_data_intervals is set. Backfills became scheduler-managed runs started from the UI or API rather than a separate CLI job (Airflow release notes). A pipeline upgraded from Airflow 2 without checking these can silently process empty intervals. The load step writes files to a new path and then updates the catalog's partition location (or commits a table-format snapshot), which is the atomic switch readers observe.
Layer 3The machine
A replication slot pins WAL on the database's disk. A busy primary writing 50 MB/s of WAL fills 180 GB per hour if the connector stops, so a dead connector can fill the primary's disk and take down production. The cap, max_slot_wal_keep_size, defaults to -1 (unlimited) and invalidates the slot when exceeded; Postgres 18 adds idle_replication_slot_timeout to invalidate slots that sit unused (Postgres docs). Either way the connector must re-snapshot, which is cheaper than a full disk. A slot on a quiet database can also pin WAL while connected, because other databases on the same server keep writing; a periodic heartbeat write lets it advance. Each commit on the source is an fsync of WAL, typically 0.5 to 2 ms on SSD, and CDC adds no extra fsyncs because it reads the same log. On the load side, writers buffer rows into Parquet row groups (128 MB by default in parquet-java (parquet-java)) in memory before compressing and uploading them; S3 accepts multipart uploads of up to 10,000 parts of 5 MiB to 5 GiB each (AWS docs), and each PUT costs 20 to 100 ms, so writing thousands of tiny files is dominated by request latency rather than bandwidth.
Why it's built this way
- Source databases are tuned for transactions, and long analytical reads hurt them, so pipelines read the replication log instead of querying tables.
- The log is ordered and contains deletes, so it is the only complete record of change; but it is retained only briefly, so CDC lands it in cheap durable storage quickly.
- Jobs fail and retry, so every write must be idempotent: keyed by partition and deduplicated by log position.
- Readers must never see partial results, and object stores lack atomic directory renames, so publication is a single metadata update.
- Pipelines break silently, so checks gate publication and freshness is measured as an SLO, end to end rather than per stage, since one stage can succeed while the next drops a field (SRE workbook).
Diagram
flowchart LR
DB["OLTP database"] -->|"CDC / binlog"| RAW["Raw zone"]
API["SaaS API"] -->|"incremental pull"| RAW
RAW --> STG["Staging: typed, deduped"]
STG --> QC{"Quality checks"}
QC -->|"pass"| SWAP["Atomic partition swap"]
QC -->|"fail"| ALERT["Alert, keep old data"]
SWAP --> MART["Marts: facts and dims"]
ORCH["Orchestrator"] -.->|"schedule, retry, backfill"| STGWorked example
An e-commerce company wants a daily orders_fact table. The orders table holds 400 million rows (about 180 GB) and receives 3 million inserts and 1 million updates per day.
A nightly full snapshot would move 180 GB and hold a long read transaction on a replica for about 40 minutes. An incremental cursor on updated_at moves only 4 million rows, about 2 GB, in under a minute, but cancelled orders that are hard-deleted never appear. The team chooses CDC through Debezium into Kafka, landing roughly 4 million change events per day into the raw zone as hourly Parquet files.
The staging job deduplicates to the latest version per order_id within the day:
INSERT OVERWRITE staging.orders PARTITION (ds = '2026-10-14')
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY order_id ORDER BY commit_lsn DESC) AS rn
FROM raw.orders_cdc WHERE ds = '2026-10-14'
) WHERE rn = 1;Then orders_fact is merged with this partition. Checks run before publish: row count within 30 percent of the 7-day average, no null customer_id, revenue total within 5 percent of the payments system. A failed check leaves yesterday's table in place and pages the data on-call.
In the wild
- Uber, 2018: full-snapshot rebuilds give way to incremental upserts. Uber's lake rebuilt main tables from full snapshots: one rebuild took over 20 hours on more than 1,000 Spark executors, rewriting a table of over 100 TB to add about 100 GB of new data, so data was fresh once every 24 hours. Apache Hudi added upserts and incremental pulls on Parquet; ingestion jobs then ran every 10 to 15 minutes and latency fell below one hour (Uber).
- Decodable/Gunnar Morling, 2022: the slot that ate the disk. A stopped Debezium-based connector left an inactive logical slot on an Amazon RDS Postgres 14 instance. RDS writes a heartbeat every five minutes and uses 64 MB WAL segments, so the "idle" database retained about 18 GB of WAL a day and filled a 200 GiB disk in under two weeks. The advice: alert when a slot retains more than about 100 MB, drop unused slots, and emit periodic heartbeat messages (morling.dev).
- Segment, 2017: deduplicating at the consumer. About 0.6% of ingested events were duplicates, mostly clients on flaky networks resending. Segment partitioned Kafka by message ID and gave each worker an embedded RocksDB of IDs seen in a four-week window; after three months it held about 60 billion keys in 1.5 TB on disk (Segment).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Classic ETL (transform before load) | Only clean data lands; less warehouse cost | Raw data lost; reprocessing needs the source again | Strict PII rules, small fixed targets |
| ELT (load raw, transform in warehouse) | Replayable raw history, SQL-based transforms, elastic compute | Warehouse bill grows; raw zone needs governance | Most modern analytics stacks |
| Incremental cursor extract | Simple, cheap | Misses deletes; clock skew gaps | Append-only or soft-delete sources |
| CDC extract | Exact, ordered, includes deletes | Connectors, schema evolution, log retention to manage | Large mutable tables, near-real-time needs |
Pitfalls in production
- Non-idempotent appends. A retried job inserts the same day twice and revenue doubles. Always overwrite or merge by partition and key.
- Late-arriving data. Events for yesterday arrive today and never get processed. Reprocess a trailing window (for example the last 3 days) on every run, or partition by arrival date and handle event time downstream.
- Silent schema changes. A source team renames a column and the pipeline loads nulls for a week. Enforce schema contracts and fail loudly on unexpected changes.
- Watermark gaps. Using
updated_at > last_run_timedrops rows committed late by long transactions. Overlap the window and dedupe, or use CDC log positions. - Green jobs, wrong data. Every task succeeds but a filter bug drops half the rows. Add volume, null-rate, and reconciliation checks that block publishing.
Leadership lens
- Ask for each pipeline's freshness SLA and owner. A pipeline without an owner becomes everyone's 3 a.m. problem.
- Insist that every job is a pure function of its logical date and inputs. That one rule turns backfills, retries, and bug fixes into routine operations.
- Agree on data contracts with source teams. Most pipeline incidents start with an upstream change nobody announced.
- Watch the cost curve: ELT moves spend to warehouse compute. Incremental models and partition pruning keep it in check.
Check yourself
Why does an incremental extract based on
updated_atmiss data even when the column is always set?Show answer
It cannot see hard deletes, and a long transaction can commit a row with an
updated_atearlier than the last watermark, so the row falls behind the cursor.What does a logical date give you that wall-clock scheduling does not?
Show answer
Each run is tied to a specific data interval, so reruns and backfills recompute exactly that interval and produce the same result.
Why keep the raw layer in ELT even after staging tables exist?
Show answer
It lets you rebuild every downstream table after a logic bug without going back to the source system, which may no longer have the history.
Sources and further study
Primary
- PostgreSQL: logical decoding concepts: replication slots, the WAL and catalog rows they retain, and failover slot synchronisation.
- Airflow 3.0 release notes: the logical-date, catchup and backfill changes that affect every scheduled pipeline.
Systems docs
- Debezium PostgreSQL connector: snapshot modes, WAL disk space consumption, heartbeats and failover slots.
- dbt incremental models: merge, delete+insert and insert-overwrite strategies for idempotent loads in the warehouse.
Books
- Fundamentals of Data Engineering, Joe Reis and Matt Housley, chapters 7 "Ingestion" and 8 "Queries, Modeling, and Transformation".
- Designing Data-Intensive Applications, Martin Kleppmann, chapter 11 "Stream Processing", section "Databases and Streams": change data capture and log compaction (chapter 12 in the 2026 second edition).
Other curricula
- Google SRE workbook, "Data Processing Pipelines": frames a pipeline's health as freshness, correctness and coverage SLOs measured end to end.
- roadmap.sh Software Architect: lists "ETL Datawarehouses" as one node, with ETL framed as feeding a warehouse rather than as an operational system.
Watch
- Functional Data Engineering: A Set of Best Practices (AI Council): Maxime Beauchemin on idempotent, partition-based pipelines.
- ETL vs ELT (Kahan Data Solutions): a short comparison of where the transform step lives.
Free resources
- Extract, transform, load (Azure Architecture Center): ETL versus ELT with reference architectures.
- Airflow best practices: idempotent tasks, logical dates, and avoiding top-level code.
- How we structure our dbt projects: a concrete layering of staging, intermediate, and mart models.
- Debezium architecture: how log-based CDC connectors read database logs into Kafka.