Idempotent Loads: Upsert & MERGE
Make a re-run produce the same result: no duplicated rows.
Idempotent loads: run it twice, get the same table
Pipelines fail and get re-run. A backfill reprocesses last week. The same daily file lands twice. If
your loader is a blind INSERT, every re-run duplicates rows, the cardinal data-engineering sin.
A loader is idempotent when running it N times leaves the target in the same state as running it
once. The test is blunt, and it's exactly what the Level 4 grader does: run the script twice, assert
the row count is identical.
The tool is an upsert: insert the row if its key is new, otherwise update the existing row. SQLite
(and Postgres) spell it INSERT … ON CONFLICT:
INSERT INTO dim_customer (email, name, city)
SELECT email, name, city FROM stg_customer
WHERE true
ON CONFLICT(email) DO UPDATE SET
name = excluded.name,
city = excluded.city;
ON CONFLICT(email)names the unique key that defines "same row." (emailmust have aUNIQUE/PRIMARY KEYconstraint.)DO UPDATE SET … = excluded.<col>overwrites with the incoming values;excludedis the row that would have been inserted.- Run it twice: the first run inserts, the second run hits the conflict and updates to the same values: zero new rows. Idempotent.
Use DO NOTHING instead of DO UPDATE when you only want inserts-if-absent and never want to touch
existing rows.
Incremental loads with a high-water mark
For large sources you don't reprocess everything; you load only rows newer than the last successful load, tracked as a high-water mark:
INSERT INTO fact_events (event_id, payload, event_ts)
SELECT event_id, payload, event_ts
FROM stg_events
WHERE event_ts > (SELECT COALESCE(MAX(event_ts), '1970-01-01') FROM fact_events)
ON CONFLICT(event_id) DO NOTHING;
The WHERE event_ts > MAX(...) skips already-loaded rows; the ON CONFLICT DO NOTHING is the safety
net for overlap at the boundary. Together they're both efficient and idempotent. (Whether you use
> or >= at the exact boundary barely matters when ON CONFLICT absorbs any re-seen key, but a
strict cutoff of either kind has a subtler failure mode, covered next.)
Late-arriving data: why a strict high-water mark silently drops rows
A high-water mark of MAX(event_ts) quietly assumes events arrive in event-time order: that once
you've recorded 2026-03-02, nothing older will ever appear again. Real sources violate that
constantly: a mobile client was offline, an upstream queue retried, a partner's nightly file ran a day
late. The event happened at event_ts = 2026-02-28, but it only lands in your source after the
load that already advanced the high-water to 2026-03-02. A strict WHERE event_ts > high_water (or
even >=) filters that row out on every future run: it is silently dropped, and no error is ever
raised.
The fix is a lookback (a "safety lag"): reprocess a trailing window instead of a hard cutoff.
INSERT INTO fact_events (event_id, payload, event_ts)
SELECT event_id, payload, event_ts
FROM stg_events
WHERE event_ts >= date(
(SELECT COALESCE(MAX(event_ts), '1970-01-01') FROM fact_events),
'-3 days' -- reprocess the last 3 days on every run
)
ON CONFLICT(event_id) DO UPDATE SET payload = excluded.payload;
You deliberately re-scan the last N days each run. Rows you've already loaded hit ON CONFLICT and
update in place (harmless, precisely because the load is idempotent) while a genuinely late row
inside the window finally gets inserted. The lookback trades a little repeated work for the guarantee
that nothing older-than-cutoff is lost. Size N to your worst realistic lateness (an hour, a day, a
week): too small and stragglers still slip through, too large and every run reprocesses more than it
needs.
In the warehouse: production incremental models never trust a bare
MAX. dbt's incremental strategy exposes alookbackconfig for exactly this, and teams write an explicitWHERE event_ts >= (SELECT MAX(event_ts) FROM {{ this }}) - INTERVAL 'N days'. Snowflake/BigQuery streaming and Spark structured streaming call the same idea a watermark with allowed lateness. The lookback only works because the merge underneath is idempotent; the two techniques are a pair.
Common pitfalls
- No unique constraint = no conflict = duplicates.
ON CONFLICT(email)only fires ifemailis actually declaredUNIQUE/PRIMARY KEY. Without the constraint, the insert just appends. Idempotency is enforced by the schema, not the query. INSERT … SELECT … ON CONFLICTparse quirk: SQLite needs aWHERE(useWHERE trueif you have no real filter) beforeON CONFLICTwhen the source is aSELECT, to disambiguate the grammar. A bareINSERT … SELECT … ON CONFLICT …can fail to parse.- Upserting the wrong key. Conflict on a non-business column (e.g. a surrogate key) never matches the natural duplicate, so re-runs still duplicate. Conflict on the natural / business key.
In the warehouse: Snowflake / BigQuery / SQL Server use
MERGE INTO target USING source ON <key> WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT. SQLite / Postgres useINSERT … ON CONFLICT. Same idea, different keyword, and interviewers expect you to name both.
Recap: an idempotent loader survives re-runs without duplicating rows; achieve it with
INSERT … ON CONFLICT(<business key>) DO UPDATE / DO NOTHING backed by a real UNIQUE constraint,
optionally scoped by a high-water-mark WHERE with a lookback window so late-arriving rows aren't
silently dropped, and prove it with the "run twice, same count" test (warehouses spell the same
operation MERGE).
Execution mode: you write a multi-statement script. It runs against a fresh seeded SQLite DB, then hidden assertion queries check the row count and the merged values, and the grader re-runs your whole script to confirm the row count doesn't move.
Apply
Your turn
The task this lesson builds to.
Write a load that leaves dim_product in exactly this state:
- a new SKU (
SKU3) is inserted, - an existing SKU (
SKU1) has itsnameandpriceupdated, SKU2(not in the extract) is left untouched,- and re-running the load adds no rows: the table stays at exactly 3.
dim_product already exists with sku declared UNIQUE and two rows in it; stg_product holds a fresh extract (already seeded). Get there by converting a blind INSERT into a single INSERT … SELECT … ON CONFLICT(sku) DO UPDATE upsert keyed on the unique sku.
3 hints and 5 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Write an incremental upsert loader with a lookback window and prove idempotency.
fact_events accumulates events keyed by a unique event_id; stg_events is a fresh extract
(both seeded). The extract overlaps already-loaded data (it repeats e2), contains a
duplicate event_id within itself (e3 appears twice with different ingested_at), brings
genuinely new events (e3, e4), and carries one late-arriving event e5 whose event_ts
(2026-02-28) is before the 2026-03-02 high-water: it happened in the past but only landed in
the source now.
In one INSERT … SELECT … ON CONFLICT load:
- dedup the extract so each
event_idappears once, keeping the row with the latestingested_at; - bound it with a lookback window on
event_ts:>= date(MAX(event_ts), '-3 days'), not a strict> MAX, so the late-arrivinge5inside the window is still picked up instead of being silently dropped; - upsert on
event_idso overlap re-seen inside the window updates in place instead of duplicating.
After one run fact_events must hold e1, e2, e3, e4, e5 (5 rows), e3's payload must be the
later-ingested {"a":3b}, the late e5 must be present, and running the whole script twice must
still leave 5 rows with the same e3 payload.
4 hints and 6 automated checks are waiting in the workspace.