How do you make a re-run of an incremental batch load land the same window without duplicates?
answer
- fix the bounds, do not use now()
- insert is never safe to repeat
- merge on identity or replace the partition
- one row per key before merging
- run it twice and compare
basics
~20 sGive the run explicit half-open window bounds instead of reading whatever is new, then apply the slice with a keyed write: an upsert on the row's identity, or an atomic replacement of the partition the window covers. Both make a replay a no-op.
solid answer
~50 sTwo things together make a replay safe. First, **fix the window**: the run takes `[lo, hi)` as parameters and computes nothing from `now()`, so re-running it reads exactly the same rows it read the first time. Second, **make the write keyed rather than additive**. Either upsert on the source row's identity — `MERGE` on the primary key, latest version wins — or delete-and-reinsert the window's partition inside one transaction so the partition is replaced rather than added to. If the source slice can itself contain several versions of one key, deduplicate in staging first with `row_number()` over the key ordered by the change column, or the merge will be ambiguous about which version won. A plain `INSERT ... SELECT` is not idempotent under any circumstances, and a retry after a timeout — where the first attempt actually committed — is exactly how duplicates get into production.
code
sql · 4 lines-- NOT idempotent: a retry after a lost acknowledgement doubles the window
INSERT INTO orders
SELECT * FROM orders_src
WHERE updated_at >= :lo AND updated_at < :hi;go deeper
Know that running the same load twice must not double the rows, and that a plain INSERT of the slice always does. The write has to be keyed.
Explain both idempotent shapes — upsert on the primary key, or atomic replacement of the window's partition — and why the run must take explicit window bounds rather than computing them from the clock.
Expect the messy parts: deduplicating multiple versions of a key inside one slice, making the update conditional so a replayed old window cannot overwrite newer data, and how you would test the property.
Make idempotency a platform property rather than a per-job virtue: parameterised windows, a standard merge template, staging conventions, and a duplicate check that runs whether or not the team remembered.
## Why replays are not optional A batch load will be re-run. The orchestrator retries after a timeout, an operator reruns a task that failed at the last step, a lookback window deliberately re-reads yesterday, or a backfill overlaps the nightly job. The question is never whether the same window is applied twice, only whether the second application changes anything. The nastiest version is the retry after an ambiguous failure: the load committed, the acknowledgement was lost, the orchestrator marks the task failed and runs it again. Nothing is broken and nothing errors — the fact table just quietly holds every affected row twice, and someone notices weeks later when a total is wrong. ## Requirement one: a fixed window A run that computes its own bounds from the current time is not replayable, because "since the last watermark, up to now" is a different set of rows every time you evaluate it. Give the run explicit half-open bounds — `updated_at >= :lo AND updated_at < :hi` — supplied as parameters and recorded in the run log. Now the run is a function of its parameters. Replaying it means running it with the same `lo` and `hi`, which reads the same rows, which lets the write be a no-op. This also makes backfills and window replays trivially expressible: the same job, different bounds. Any design where "rerun yesterday" requires editing the watermark table by hand is a design that will be got wrong under pressure. ## Requirement two: a keyed write There are exactly two idempotent shapes for applying a slice. **Upsert on identity.** `MERGE INTO target USING staging ON target.pk = staging.pk`, updating when matched and inserting when not. Applying the same slice twice sets the same columns to the same values. This is the general answer and works whether or not the target is partitioned. **Replace the partition.** Delete every target row in the window and insert the slice, in one transaction — or, on formats that support it, overwrite the partition atomically. This suits append-shaped data with a natural time partition (events, log-like facts) where per-row upserting is expensive. The critical detail is that the delete predicate must exactly match the window the insert covers, or you will delete rows you are not about to reinsert. What is never idempotent is `INSERT INTO target SELECT ... FROM staging`. Not with a unique index — that turns duplication into a hard failure, which is better than silence but still means every retry needs manual intervention. Not with `INSERT IGNORE`-style suppression either, which silently drops legitimate updates to existing keys. ## The third requirement people forget: dedupe within the slice A lookback window or a wide backfill chunk often contains several versions of the same key — a row updated three times inside the window arrives three times. Most engines' `MERGE` is undefined or errors when the source matches one target row more than once, and even where it succeeds you may not get the version you wanted. Collapse the slice to one row per key before merging, keeping the newest by the change column: ```sql SELECT * FROM ( SELECT s.*, row_number() OVER (PARTITION BY order_id ORDER BY updated_at DESC) AS rn FROM stg_orders_slice s ) WHERE rn = 1; ``` Where ties on the change column are possible, add a deterministic tiebreaker so two runs over the same data pick the same winner — otherwise the load is idempotent in row count but not in content. ## Guard against out-of-order application If windows can be applied out of order — a replayed old window running after a newer one has already landed — an unconditional upsert will overwrite a newer row with an older version. Make the update conditional: only overwrite when the incoming change value is greater than or equal to the one already stored. That turns the load from merely idempotent into order-insensitive, which is what a backfill running alongside a nightly job actually needs. ## Staging is part of the pattern Write the extracted slice to a staging table or a landing path named for the window before touching the target. It gives you a place to dedupe, a place to run row-count and null checks before publishing, and a clean truncate-and-refill on retry. Staging tables should be per-window or truncated at the start of the run, never appended to across runs, or the staging table itself becomes the thing that duplicates. ## How to verify you actually got it right Do not assert idempotency, test it. In a test environment, run the same window twice and assert that the target's row count, checksums and a per-key hash are unchanged by the second run. Add a scheduled duplicate check on the natural key of critical tables in production. Most teams that believe their loads are idempotent have never run the load twice on purpose.
- Why can a MERGE fail or misbehave when the staging slice holds two rows for one key?Because the join matches a single target row more than once, and the standard leaves that case an error or undefined. Collapse the slice first with row_number() over the key ordered by the change column descending, keeping rank one, and add a deterministic tiebreaker so repeated runs pick the same winner.
- When is delete-and-reinsert of a partition better than a row-level upsert?When the data is append-shaped and naturally time-partitioned, and the window maps exactly onto whole partitions. Rewriting a partition is often far cheaper than matching millions of keys, and on columnar formats it is a metadata operation. It is wrong when the window straddles partitions or when rows can move between them.
- How do you stop a replayed old window from overwriting newer values already in the target?Make the update conditional on the change column: only overwrite when the incoming value is greater than or equal to the stored one. That makes the load order-insensitive, which matters as soon as a backfill runs concurrently with the nightly job.
- How would you prove a load is idempotent rather than assume it?Run the same window twice against a copy and assert that row counts, per-key hashes and column checksums are identical after the second run. Then keep a scheduled duplicate check on the natural key in production, because a schema or key change can quietly break the property later.
saying these in an interview costs you the question
- Appending the slice and relying on retries never happening
- Deriving the run's window from now() instead of parameters
- Merging a slice that still holds several versions per key
- Assuming a unique index makes a load idempotent
- Deleting a wider range than the insert will repopulate