Education › Data › Stage 2: Pipelines

Batch pipeline design

ETL vs ELT, idempotency, backfills, late data, and designing a pipeline you can re-run safely.

Intermediate ~35 min read Module 5 of 16

A batch pipeline is a promise: run me for a date and I will produce that date's data, correctly, every time, including the second time. Most pipeline pain — duplicated rows after a retry, a backfill that overwrote good data with stale data, late records that never arrived, a run that silently produced half a table — comes from pipelines that were written to run once and hoped for the best. This module is about designing batch pipelines that keep the promise: the ETL versus ELT choice, extraction patterns that scale, idempotent loads, late-arriving and changing data, backfills you can trust, and the operational shape that makes all of it routine.

After this module you can
  • Choose between ETL and ELT and explain the trade-offs for a given stack
  • Extract data incrementally with watermarks, and know when a full extract is safer
  • Design idempotent loads with partition overwrite, upsert and atomic swaps
  • Handle late-arriving and updated records without double counting
  • Run backfills and reprocessing safely, and keep pipelines observable

ETL, ELT and where transformation lives

ETL transforms data in flight, between source and destination, in a processing engine (Spark, a Python job, a dedicated tool), and loads the finished shape. ELT loads raw data into the warehouse or lakehouse first and transforms it there, in SQL, using the destination's compute. Cheap, elastic warehouse compute and tools like dbt made ELT the default for analytics: raw data is preserved, transformations are versioned SQL, and the warehouse's optimiser does the heavy lifting. ETL remains right when the transformation needs code the warehouse cannot run (complex parsing, machine learning features, external enrichment), when data must be reduced before it lands (cost, privacy), or when the destination is not a query engine.

QuestionFavours ETLFavours ELT
Where is compute cheap and elastic?Dedicated clusterThe warehouse
Must raw data be preserved for replay?Store it anywayYes, by design
Is the logic expressible in SQL?No: code, ML, parsingYes
Must sensitive fields be removed before landing?YesLand to a restricted zone, then mask
Who maintains transformations?Engineers in codeAnalysts and engineers in versioned SQL

In practice most platforms are both: a thin extract-and-load layer that lands raw data faithfully, a SQL transformation layer for modelling, and code-based jobs for the few things SQL cannot do. The principle that matters more than the acronym is load raw, transform explicitly: keep the source's shape at the first hop, so every later step is reproducible.

Extraction: incremental, with watermarks

Pulling an entire table every night works until the table is large. Incremental extraction pulls only what changed since the last run, tracked by a watermark: the maximum updated_at (or id, or log position) seen in the previous successful run. Each run selects rows past the watermark, loads them, and advances the watermark only after the load commits. Three rules keep it honest: overlap the window slightly to catch rows with equal timestamps or clock skew (and deduplicate on load), store the watermark with the run's state rather than in someone's head, and choose a watermark column the source actually maintains — an updated_at that some code paths forget to set will lose rows silently.

Incremental extract with a persisted watermark and a small overlap. The watermark advances only after the load succeeds.
python
from datetime import datetime, timedelta, timezone

OVERLAP = timedelta(minutes=5)


def extract_incremental(conn, state, table: str, ts_col: str = "updated_at"):
    since = state.get_watermark(table) - OVERLAP          # re-read a little to be safe
    now = datetime.now(timezone.utc)
    rows = conn.execute(
        f"SELECT * FROM {table} WHERE {ts_col} > %s AND {ts_col} <= %s ORDER BY {ts_col}",
        (since, now),
    ).fetchall()
    return rows, now


def run(conn, state, loader):
    rows, new_watermark = extract_incremental(conn, state, "orders")
    loader.upsert("orders", rows, key="order_id")       # overlap rows collapse on the key
    state.set_watermark("orders", new_watermark)         # only after the upsert committed

Some sources cannot be extracted incrementally: no reliable timestamp, hard deletes that leave no trace, or small reference tables where a full pull is trivial. For those, full extracts with a snapshot comparison are simpler and safer. For high-volume transactional sources the better answer is change data capture from the database log, which a later module covers; the watermark pattern is the workhorse for APIs and for tables that maintain their timestamps.

Watch out

Deletes are invisible to timestamp-based extraction. If the source hard-deletes rows, you need soft deletes upstream, a periodic full reconciliation, or log-based CDC. Assuming deletes do not matter is how a warehouse ends up reporting orders that no longer exist.

Loading idempotently

The load is where re-runs go wrong. Three patterns make a load idempotent — safe to repeat with the same result. Partition overwrite: the run computes one logical partition (a day, an hour) and replaces it atomically; a retry replaces it again with the same content. Upsert (merge) keyed on a natural key: rows that exist are updated, new ones inserted, and the overlap from the extract collapses. Atomic swap: build the new table under a temporary name, validate it, then swap names in one transaction so readers never see a partial state. Choose by grain: partition overwrite for event-like data with a time partition, upsert for entity-like data that changes, swap for full rebuilds of small-to-medium tables.

Two idempotent loads: partition overwrite for events, and build-validate-swap for a full rebuild.
sql
-- 1. partition overwrite: the whole day is replaced in one transaction
BEGIN;
DELETE FROM fact_events WHERE event_date = DATE '2026-03-01';
INSERT INTO fact_events
SELECT * FROM stage_events WHERE event_date = DATE '2026-03-01';
COMMIT;
-- (warehouses: INSERT OVERWRITE PARTITION, or MERGE on a table format)

-- 2. build, validate, swap: readers see the old table until the instant of the swap
CREATE TABLE dim_product_new AS SELECT ... FROM stage_products;
-- validation queries run here: row count within 10% of current, no NULL keys, keys unique
BEGIN;
ALTER TABLE dim_product     RENAME TO dim_product_old;
ALTER TABLE dim_product_new RENAME TO dim_product;
COMMIT;
DROP TABLE dim_product_old;

Append-only loads are idempotent only if every row carries a unique id and the destination deduplicates, or if the pipeline records which batches were loaded and skips repeats. Plain INSERT of a batch with no key is the single most common source of duplicated data in warehouses; treat it as a bug in the design, not an operational risk to manage.

Late, changed and out-of-order data

Events arrive late: a mobile device syncs a day later, a partner sends yesterday's file this afternoon, a record is corrected a week after it was created. A pipeline that processes "yesterday" once and moves on will miss them. Two design choices handle this. Partition by event time, not arrival time, so a late event lands in the partition where it belongs; and reprocess a trailing window — recompute the last N days on every run, where N covers most lateness — so late arrivals are picked up automatically. Beyond the window, a manual backfill handles the rare very late record.

Trailing-window reprocessing: each daily run rebuilds the last three event-date partitions, which absorbs late data and corrections.
python
from datetime import date, timedelta

LOOKBACK_DAYS = 3


def partitions_to_rebuild(run_date: date) -> list[date]:
    return [run_date - timedelta(days=i) for i in range(LOOKBACK_DAYS)]


def daily(run_date: date, build_partition):
    for day in partitions_to_rebuild(run_date):
        build_partition(day)          # idempotent overwrite of that day's partition


# run on 2026-03-04: rebuilds 03-04, 03-03, 03-02 from the raw layer
daily(date(2026, 3, 4), build_partition=lambda d: print("rebuilding", d))

Updates to existing records are the same problem with a different face: an order's status changes from placed to paid. For entity tables, upsert on the key. For fact tables that must not lose history, load status changes as new events and derive the current state with a window function, or maintain an accumulating snapshot as the modelling module described. Whichever you choose, write down how each table treats late and changed data; it is the first question anyone debugging a discrepancy will ask.

Backfills, reprocessing and operations

A backfill runs the pipeline for a range of past dates: after a bug fix, after a new column is added, when a new consumer needs history. Backfills are safe only if every step is idempotent and parameterised by logical date, which is why those properties were non-negotiable. Run backfills as a distinct operation with its own concurrency limit (they compete with the daily run for warehouse compute and source rate limits), from the raw layer rather than from the source system when possible, and validate a sample of rebuilt partitions against the previous version before dropping it.

  • Logging and metrics per run: rows extracted, loaded, rejected; duration; watermark before and after. A run that loads zero rows on a Tuesday should alert.
  • Freshness checks: the newest event time in each table versus now; the SLA is a number.
  • Reconciliation: periodic counts or sums compared with the source for a window; the only way to catch silent loss.
  • Small, single-purpose steps: extract, stage, transform, publish — each restartable, each with a clear input and output.
  • Schema change handling: new columns added automatically to raw; type or rename changes fail loudly with a message that names the column.
Tip

The pipeline you can trust is the one you can rerun in front of a stakeholder: pipeline --day 2026-03-01 finishes, the numbers match yesterday's, and the run's log says exactly what happened. Build every pipeline so that demonstration is boring.

Hands-on practice

A pipeline that survives retries, late data and a backfill

  1. Create a source table orders in Postgres or DuckDB with updated_at, and a destination fact_orders partitioned (logically) by order_date. Seed a few hundred rows across five days.
  2. Implement incremental extraction with a persisted watermark (a one-row state table) and a five-minute overlap. Run it twice in a row; the second run should extract only the overlap and load nothing new.
  3. Implement the load as partition overwrite by order_date. Kill the process halfway through a run (raise an exception after the delete) and confirm that re-running produces the correct final state.
  4. Insert a late order dated three days ago and update an existing order's status. Run the pipeline with a three-day lookback and confirm both are reflected without duplicates.
  5. Introduce a deliberate bug in the transformation (wrong sign on refunds), run for a week of dates, fix it, and backfill the week. Compare row counts and sums before and after.
  6. Add freshness and row-count checks that fail the run when the newest order_date is older than two days or a partition loads zero rows. Trigger each once.
  7. Write the table's contract in a comment: partition key, how late data is handled, how updates are handled, how to backfill.
Cheat sheet

Batch pipeline design — at a glance

Main things to focus on

  • Load raw, transform explicitly; ELT for SQL-expressible analytics, ETL for code, reduction and non-query destinations
  • Incremental extraction with a persisted watermark, a small overlap and dedup on load; watermark advances after commit
  • Timestamp extraction cannot see hard deletes; reconcile or use CDC
  • Idempotent loads: partition overwrite, keyed upsert, or build-validate-swap; never bare appends
  • Partition by event time; reprocess a trailing window to absorb late and corrected data
  • Backfills are only safe for idempotent, date-parameterised steps; validate before dropping old data

Extraction

WHERE updated_at > :wm - overlap AND updated_at <= :nowIncremental window with overlap
state.set_watermark(table, now) after load commitsNever advance before success
full extract + snapshot compareSmall tables or no reliable timestamp
soft deletes / periodic reconciliation / CDCThe only ways to see deletes
ORDER BY watermark columnDeterministic, resumable extraction

Idempotent loads

DELETE partition; INSERT partition; in one transactionPartition overwrite
INSERT OVERWRITE PARTITION / MERGE INTOWarehouse and table-format equivalents
INSERT ... ON CONFLICT (key) DO UPDATEKeyed upsert for entity tables
CREATE t_new AS ...; validate; RENAME swap; DROP oldAtomic full rebuild
batch id + loaded_batches tableMake appends skip repeats

Late and changed data

partition by event_date, not load_dateLate rows land where they belong
rebuild last N partitions every runTrailing-window reprocessing
status changes as events + ROW_NUMBER() latestKeep history, derive current
accumulating snapshot for milestonesUpdates in place by design
document lateness and update policy per tableFirst question in any discrepancy

Operations

pipeline --day D / --start S --end ELogical dates for runs and backfills
rows in / out / rejected, duration, watermarksPer-run metrics; zero rows alerts
freshness = now - max(event_time)SLA as a number
reconcile counts/sums vs source weeklyCatch silent loss
backfill from raw, limited concurrency, validate sampleSafe reprocessing

Common pitfalls

  • Advancing the watermark before the load commits, then losing a batch when the load fails.
  • Bare INSERT loads; every retry doubles the data.
  • Partitioning by load date, so a late event for Monday lands in Thursday's partition and Monday's numbers are wrong forever.
  • Assuming the source never deletes rows.
  • Backfilling with a job that reads 'today' from the clock.
  • No reconciliation, so silent row loss is discovered by a customer.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →