A load is idempotent when running it twice over the same input leaves the warehouse in the same state as running it once — formally, f(f(x)) = f(x). Those four words carry the whole definition, and dropping them is how the idea gets misread as “a re-run never changes anything”, which is not the claim:
Same extract, run twice → nothing moves. That is idempotence.
The 04.03 extract lands. The load runs at 03:00 on 05.03 and writes 12,400 order lines. Run it again against that same extract and the target is byte-identical — no second 12,400 rows, no re-versioned customers.
New extract arrives, re-run picks it up → also correct.
If the 05.03 extract has landed by the time you re-run and 900 new lines appear, nothing is broken. The input changed, so the output is allowed to. Judging that as a failure is how teams conclude a correct load is unsafe.
Same extract, run twice → 24,800 rows. That is the bug.
Same input, different state. This is the only one of the three that is a defect, and it is the one the test below catches.
Note also what the property belongs to: it is a property of the load, not of the data and not of the scheduler. No amount of retry logic upstream makes a non-idempotent load safe to repeat.
It matters because a re-run is not an edge case — it is the normal ending of every incident. A failed job, a late extract, a corrected source file, a deployment rolled back: each one ends with somebody running the load again, usually at night, usually under pressure. If the load is idempotent that decision is free. If it is not, the cost of the recovery is a silently wrong number, and the person best placed to notice is asleep.
The test is the definition: run the load, snapshot the target, run the identical load again, and assert the two snapshots are equal. Everything interesting is in what you are allowed to exclude from the comparison.
Why
gold.fct_order_line is loaded by MERGE on order_line_sk. Change that to an append — which someone will, because appends are faster and the sk is unique in the source — and a re-run doubles amount_eur for one day. Nothing errors. gold.agg_revenue_month is wrong by exactly one day's revenue, in a month, in a chart.
The person re-running the job at 03:00 is the least equipped to judge whether it is safe. Idempotency is how you make that judgement unnecessary.
How
pythontests/invariants/test_idempotency.py
# Importable only because the entry point is split from the module: the
# dbutils.widgets.get() calls live in the __main__ half, so this import does
# not raise outside a notebook. See the standards route, and the gotchas.
from dwh.jobs import silver_customer
# Wall-clock only. If you have to add anything else to this list to make the
# test pass, you have found the bug, not the exception.
VOLATILE = ["_tech_loaded_at"]
def snapshot(spark, table: str) -> list:
return (
spark.read.table(table)
.drop(*VOLATILE)
.orderBy("customer_id", "_tech_valid_from")
.collect() # collect NOW — see the gotchas
)
def test_silver_customer_load_is_idempotent(spark, catalog):
# run(spark, source, target) — the same two names the entry point assembles
# from its widgets. The test passes them; the function never invents them.
source = f"{catalog}.bronze.sap_kna1"
target = f"{catalog}.silver.customer"
silver_customer.run(spark, source, target)
first = snapshot(spark, target)
silver_customer.run(spark, source, target) # same bronze input, nothing new
second = snapshot(spark, target)
assert second == first
_tech_loaded_at is genuinely wall-clock and genuinely differs. _tech_valid_from is not — if the second run moved it, the load re-versioned every customer, and the exclusion list is hiding the defect it was written to expose.
The test tells you whether a load is idempotent. Six design choices are what make it so, and every one of them is a decision taken while writing the load rather than a fix applied afterwards:
Write with MERGE on a deterministic key, never append
MERGE keyed on order_line_sk is idempotent by construction: the second run matches the rows the first inserted and updates them to the same values. An append re-inserts them. This is the single choice that decides the property.
Derive surrogate keys by hashing the business key
sha2(concat_ws('||', order_id, line_number), 256) yields the same key on every run. monotonically_increasing_id(), a sequence or a UUID does not — the second run produces new keys, MERGE matches nothing, and the append problem returns wearing a surrogate key.
Inject the clock; never call current_timestamp() in the transform
A transform that reads the wall clock produces different output on every run, so it cannot be idempotent even in principle. Pass the as-of timestamp in as an argument — that is also what makes yesterday testable.
For a full reload, replace the partition rather than adding to it
replaceWhere on the day being reloaded makes the write itself replace-not-add, so re-running a day's load is safe without a MERGE key. Plain append on a re-run doubles the day.
De-duplicate the batch before the MERGE, not after
Two changes to one order_line_sk inside one extract make the MERGE non-deterministic — or fail outright — because the source matches a target row twice. Reduce to one row per key by sequencing first, and the load stops depending on which duplicate arrived last.
Re-derive the high-water mark from the target, not from a side file
A bookmark stored beside the pipeline can disagree with what actually landed — after a partial failure it usually does. Reading MAX(_tech_loaded_at) out of the target means the load's idea of where it got to cannot drift from the truth.
Doing it
Write the idempotency test before choosing the load patternIt is the thing that rules out append.
Call run(spark, source, target) with fully qualified namesA job that assembles its own table names from a catalog cannot be pointed at fixtures, so the test either runs against real data or does not run.
Compute _tech_hash over business columns onlyA technical column inside the hash makes every row change on every run.
Run every load twice in CI and assert a zero-row diff on the second pass
Keep the volatile-column list as one constant in one place, so widening it is a visible commit
Embedding it
Have them re-run yesterday's load in dev and count the rows themselves before you say anythingThe number is more persuasive than the principle.
Ask 'what happens if this runs twice?' in every pipeline review until someone asks it before you do
Let the team add the second run to CIThe suite gets slower and they will argue about it — that argument is where they decide what correctness is worth.
Defaults
Every load runs twice in CI; the second run must change nothing
MERGE keyed on the surrogate keyINSERT is for append-only bronze and nothing else.
_tech_hash covers business columns only, never technical ones
The exclusion list is one constant, reviewed like code
Gotchas
The lazy snapshot: snapshot() returns a DataFrame instead of collected rowsThe second run overwrites the table, Spark re-reads it, and both sides of the assertion are the after state. The test can never fail, and it will sit green in the suite for a year.
_tech_loaded_at inside _tech_hashEvery row differs on every run, so SCD2 closes and reopens every interval nightly. dim_customer grows by one row per customer per day and nothing errors until someone asks how many versions a customer has.
'It is idempotent because we truncate and reload'True until the source is a CDC feed that cannot be replayed, at which point the truncate is a data-loss event with a green job run.
current_timestamp() evaluated inside the transform rather than passed inTwo tasks in one run get two different values, so even a single run is not reproducible and the idempotency test flaps.
Importing a job module that calls dbutils.widgets.get() at module levelThe import raises NameError: dbutils is not defined during collection, so every test in the file errors before one of them runs — and the traceback names dbutils, not the job, so it reads as a broken test environment and gets an afternoon of environment debugging. Widget reads belong in the entry point; the importable half takes source and target as arguments.
Idempotency covers a whole job. Real failures are partial: the job wrote gold.dim_customer, then lost an executor before gold.fct_order_line. The retry starts from the top with one table already at today's state and the rest at yesterday's. Test it by injecting the failure rather than by reasoning about it.
Why
The half-updated dimension is the danger, not the missing fact. After the failed run, dim_customer already carries today's new SCD2 version. If build_fct_order_line resolves customer_sk by _tech_is_current instead of by the validity interval, the retry attaches last week's order lines to today's version. Revenue by segment shifts. No row is missing, no key is orphaned, and every test that does not compare against silver passes.
How
pythontests/invariants/test_partial_retry.py
import pytest
from dwh.jobs import gold_load
TABLES = ["gold.dim_customer", "gold.fct_order_line", "gold.agg_revenue_month"]
def fingerprint(spark, catalog: str) -> dict:
"""Order-independent content hash per table, ignoring load timestamps."""
out = {}
for t in TABLES:
row = spark.sql(f"""
SELECT count(*) AS n,
bit_xor(xxhash64(to_json(struct(*)))) AS h
FROM (SELECT * EXCEPT (_tech_loaded_at) FROM {catalog}.{t})
""").first()
out[t] = (row.n, row.h)
return out
class Boom(RuntimeError):
pass
def explode(*_args, **_kwargs):
raise Boom("simulated executor loss between tables")
def test_retry_after_partial_failure(spark, catalog, monkeypatch):
gold_load.run(spark, catalog)
clean = fingerprint(spark, catalog)
# die AFTER dim_customer is written, BEFORE fct_order_line
monkeypatch.setattr(gold_load, "build_fct_order_line", explode)
with pytest.raises(Boom):
gold_load.run(spark, catalog)
monkeypatch.undo()
gold_load.run(spark, catalog) # the retry, from the top
assert fingerprint(spark, catalog) == clean
Doing it
Write each table with one atomic statement — a MERGE or an overwriteNever a DELETE followed by an INSERT.
Resolve dimension keys by intervalorder_ts >= _tech_valid_from AND (_tech_valid_to IS NULL OR order_ts < _tech_valid_to).
Keep the injection point a parameter of the test so you can move the failure between tables
Write the answer to 'is it safe to re-run blind?' into the runbook, per job, before go-live
Embedding it
Kill a run yourself during the game day, then hand the retry to someone who did not write the jobThat is the real test.
Ask 'which step here is not atomic?' in reviewIt is a shorter question than explaining atomicity.
Have the team write the re-run answer into their own runbookA re-run policy in your handover deck is not a re-run policy.
Defaults
One atomic write per table per run
Retry-from-the-top is the default; resume-from-step needs a written justification
Failure injection lives in tests only — never a flag that exists in production code
Every job's runbook page answers the re-run question in its first three lines
Gotchas
DELETE then INSERT as two statementsThe retry lands in the gap: the delete succeeded, the insert died, the table is short a day, and the retry is green. Every dashboard shows a dip nobody can attribute.
Resolving customer_sk by _tech_is_currentIt passes every test written against a single-version dimension, and starts returning wrong history in month three, in production, without an error.
Retrying a job whose CDC source has already been consumedThe retry reads an empty feed, writes nothing, and reports success — the most expensive green run there is.
Dropping count(*) from the fingerprint because 'the hash covers it'bit_xor cancels in pairs: a row written twice XORs to zero, so a table loaded twice by a bad retry hashes identically to one loaded once. The count is what makes the fingerprint see the exact failure this test exists for.
A fingerprint mismatch is not a diffIt tells you the tables differ, not how, and two engineers will spend an hour comparing bigints by eye. When it fails, run the symmetric EXCEPT from the SQL testing route — that is the tool that names the rows.
bronze.sap_vbap is a CDC feed, so one order line can change three times inside a single batch. MERGE refuses that: if one target row matches more than one source row it fails the entire batch. The fix is not to loosen the merge condition — it is to deduplicate the source down to the merge grain, deterministically, before the merge sees it. Deterministically is the whole word: the ordering must come from SAP's own change counter, because every technical column bronze adds is constant within one batch.
Deduplication is only half of it, and the half that fails loudly. It settles which version wins inside one batch and says nothing about an older batch arriving after a newer one — a replay, which is exactly what the 03:00 re-run this route opens with produces. The second half is a sequence guard on the MATCHED branch, and it only works if the target keeps the counter: silver.sales_order_line carries change_seq for no other purpose.
Why
The visible failure is loud and survivable. The invisible one is the repair: someone adds a WHEN MATCHED AND … condition that happens to reduce the match to one row. The merge stops failing and starts keeping an arbitrary version of the line — arbitrary meaning it can differ between two runs over identical input, which quietly destroys idempotency as well.
The missing sequence guard is quieter still, and it is the one this route exists for. An unguarded merge is idempotent — replay the same batch and you write the same values — so the idempotency test one topic up stays green while the defect sits there. It needs an older batch to show itself: a re-run of yesterday's job, a backfill executed in the wrong order, a replayed CDC file. Silver then regresses to a superseded version of the line, gold rebuilds cleanly from it, and the only evidence is that a status went backwards from SHIPPED to OPEN in a warehouse where nothing errored.
How
sqlsilver.sales_order_line — deduplicate in the USING subquery, then guard the replay
-- vbap_typed is the rename-only view from the ingestion route: bronze keeps
-- SAP's field names (VBELN, POSNR, MATNR, …), the view maps them to canonical
-- ones and passes change_seq and the _tech_ columns through untouched.
MERGE INTO silver.sales_order_line AS t
USING (
SELECT order_id, line_number, product_id, quantity, unit_price, currency,
status, change_seq -- carried through: the guard needs it
FROM (
SELECT *,
row_number() OVER (
PARTITION BY order_id, line_number
-- SAP's change counter, NOT _tech_loaded_at: every row in this
-- batch shares the load time. See the gotchas.
ORDER BY change_seq DESC
) AS rn
FROM vbap_typed
WHERE _tech_batch_id = :batch_id
)
WHERE rn = 1
) AS s
ON t.order_id = s.order_id
AND t.line_number = s.line_number
-- The replay guard. Without it, re-running an older batch overwrites the newer
-- version of the line with no error and a green run. Only newer wins; an equal
-- change_seq is the same version and is a no-op, which is what keeps the
-- re-run of the SAME batch idempotent.
WHEN MATCHED AND s.change_seq > t.change_seq THEN UPDATE SET
t.product_id = s.product_id,
t.quantity = s.quantity,
t.unit_price = s.unit_price,
t.currency = s.currency,
t.status = s.status,
t.change_seq = s.change_seq,
t._tech_loaded_at = current_timestamp()
WHEN NOT MATCHED THEN INSERT
(order_id, line_number, product_id, quantity, unit_price, currency,
status, change_seq, _tech_loaded_at)
VALUES
(s.order_id, s.line_number, s.product_id, s.quantity, s.unit_price,
s.currency, s.status, s.change_seq, current_timestamp())
Those two mechanisms together are exactly what AUTO CDC INTO … SEQUENCE BY performs on your behalf: deduplicate the batch by the sequence column, then refuse anything not newer than what is already there. The comparison is therefore exact rather than approximate — if you hand-write the merge, you owe both halves, and if you use AUTO CDC you still owe a trustworthy change_seq.
Doing it
Deduplicate in the USING subquery with row_number(), on every mergeIncluding the ones where duplicates 'cannot' happen.
Persist change_seq on the merge target and guard the MATCHED branch with s.change_seq > t.change_seqDedup handles the batch; the guard handles the replay.
Make the dedup partition identical to the merge ON clauseIf they differ, one of them is wrong.
Assert the grain of the deduplicated source as a zero-rows check before the merge runs
Keep a double-change row in the fixtures schema permanently, so the case can never regress out of the suiteKeep an out-of-order pair of batches beside it, because the guard has no other test.
Embedding it
Hand them the fixture with the duplicate and let the merge fail before you explain anythingTen seconds of error message beats ten minutes of warning.
In review, ask 'what is one row of this USING subquery?'It is the same grain question as the layer contract, applied where it actually bites.
Have them put the grain assertion into the shared harnessIt then covers every merge target, not the one they were looking at.
Defaults
Every MERGE source is a deduplicated subquery with an explicit deterministic ORDER BY
The dedup key equals the merge key, always
Every MERGE onto a CDC target carries a sequence guard on the MATCHED branchThe target persists the sequence column so the guard has something to compare against.
One sequencing column, named the same everywhere, from the source systemNot from bronze, not from the pipeline.
A source-grain assertion runs in CI for every merge in the repository
Seeded duplicate rows live in fixtures forever, not just during the sprint that found them
Gotchas
A MERGE with no sequence guardReplaying an older batch — yesterday's job re-run, a backfill in source order nobody checked — overwrites the newer version of every matched line. No error, green run, and the idempotency test cannot see it because that test replays the same batch, not an earlier one. It surfaces weeks later as a shipped order that is OPEN again.
Guarding with >= instead of >Every replayed row rewrites _tech_loaded_at with an identical payload, so the merge touches rows it does not change, the Delta history fills with no-op versions, and a downstream CDF consumer re-processes the whole target on every replay.
Deduplicating with DISTINCTIt removes rows identical in every column; two CDC versions of one line differ in status, so both survive and the merge fails anyway — after the author has convinced themselves it is handled.
Ordering the row_number() by _tech_loaded_at or _tech_batch_idBoth are constant inside one batch — the batch filter guarantees it — so the tie is broken by whatever the shuffle put last. The merge succeeds every time and two runs over identical input produce two different silver tables, which shows up as an idempotency test that flaps rather than as a merge error. Order by change_seq, the source's own counter, which bronze carries unmodified.
Adding a column to the merge key to make the error go away — keying on order_id, line_number, statusIt never fails again, and it inserts a new row per status change, so 'one row per order line' is gone and every downstream count is inflated.
Assuming AUTO CDC removes the problemIt does exactly these two things for you — deduplicate the batch by SEQUENCE BY and reject anything not newer than the target — and it needs the same sequencing column to do either. Point SEQUENCE BY at _tech_loaded_at and you have declared every row in the batch equally recent: the winner is arbitrary and the replay guard is inert, with a pipeline that looks more correct than the hand-written merge it replaced.