What Is Idempotency in Data Engineering?

Idempotency in data engineering means a pipeline step can run any number of times for the same input and leave its target tables exactly as 1 run would. That is the meaning of an idempotent data pipeline: a rerun of any kind, retry or backfill, replaces or merges the data the run owns instead of appending to it, so it never creates duplicates.

Last updated: Proudly published by: Jeff Wahl13 min read

What is idempotency in data engineering?

Idempotency in data engineering means that running a pipeline step more than once for the same input leaves the target in exactly the state 1 run would. Retry it after a crash, rerun it for a backfill or start it twice by accident, and the tables end up with the same rows and the same values every time.

The word comes from mathematics, where an operation f is idempotent when f(f(x)) = f(x): taking an absolute value or rounding a number twice gives the same answer as doing it once. Web APIs use the same meaning. RFC 9110, the HTTP specification, calls a method idempotent when the intended effect of several identical requests is the same as the effect of 1, which is why PUT and DELETE may be retried automatically and POST may not.

For a pipeline, idempotency is a property of the data a step leaves behind. A step is idempotent when it owns a well defined slice of the target, such as 1 day's partition of a fact table or the rows for 1 batch of keys, and every run replaces or merges that slice instead of adding to it. Audit rows and run ids can differ between runs; the rows in that day's partition cannot.

Prepare for the interview
01 / Open invite
02min.

Know idempotency the way the interviewer who asks it knows it.

a idempotency query, the same shape a screen would give you.
The diff against expected. Where ties broke. What you missed.
sandbox
1source → bronze → silver → gold
2 ingest : CDC + Kafka
3 transform : dbt + Airflow
4 serve : Snowflake
5
Execute your solution0.4s avg.
AmazonInterview question
Solve a idempotency problem

Why data pipelines must be idempotent

Every pipeline is rerun sooner or later. Take a daily load of fact_orders whose source holds 1,000 rows for the day. The job commits 600 rows, then dies on a network error. The retry reads the same 1,000 rows and writes them all. With a plain INSERT the partition now holds 1,600 rows, 600 of them duplicates, and every revenue figure built on it is 60% too high. If the load first deletes the day and then inserts it, the retry leaves exactly 1,000.

The partial write is only the most common trigger. Backfills rerun months of history after a logic change. 2 copies of the same run overlap when a scheduler restarts or someone triggers a run by hand while the scheduled one is still going. Late data forces a day to be reloaded after its downstream reports were built. Each is routine, and any of them will corrupt the target unless the step can safely run again.

1 day's partition after a run that wrote 600 rows and failed, then a retry of all 1,000: a plain INSERT append ends at 1,600 rows with 600 duplicates, while DELETE plus INSERT ends at 1,000 rows, matching the source1 day's partition after a run that wrote 600 rows and failed, then a retry of all 1,000: a plain INSERT append ends at 1,600 rows with 600 duplicates, while DELETE plus INSERT ends at 1,000 rows, matching the source

Orchestrators assume it. Airflow's default for default_task_retries is 0, so a task retries only when its DAG sets retries, but most teams do set it, and the Airflow documentation asks that tasks be treated like database transactions that produce the same result on every rerun. In practice that rules out an INSERT that can duplicate rows on a re-run and now() in the computation, and it means pinning reads and writes to the run's own partition through data_interval_start.

What makes a pipeline non-idempotent

A bare INSERT INTO ... SELECT or a Spark write in append mode adds rows every time it runs: 2 runs double the data and 3 triple it. It's the most common violation. The fix is to replace or merge the slice the run owns.

Incrementing a stored total is the same bug in 1 row. UPDATE metrics SET page_views = page_views + 1500 adds another 1,500 on every rerun, so the counter drifts further from the truth each time. The fix is to write the absolute value computed from the source, as in SET page_views = 1500. The other fix is a ledger with 1 row per increment, keyed by its source event, with the total read as a SUM over it, so a replayed event updates its own row rather than adding a new one.

Inputs that change between runs break idempotency even when the write is correct. A step that reads "the latest data" or filters on current_date loads a different slice when it is retried an hour later or backfilled next month. Have the scheduler pass each run its interval or batch id, and make the step read and write only that slice.

Values generated at write time do the same in a quieter way. A surrogate key drawn from a sequence gives a rerun different values for the same source row, and so does a random UUID or a loaded_at = now() column. That breaks joins to earlier outputs and makes 2 runs impossible to compare. Derive keys from the source, such as a hash of the natural key, and take timestamps from the source or from the run's interval.

Idempotent write patterns compared

DELETE + INSERT
  • ReplacesThe run's key range
  • Needs a unique keyNo
  • Source deletes removedYes, in the range
  • Duplicate source keysLoaded twice
  • Atomic through1 transaction
  • Typical targetDaily fact tables
MERGE or upsert
  • Replaces1 row per key
  • Needs a unique keyYes
  • Source deletes removedNo
  • Duplicate source keysError or random pick
  • Atomic through1 statement
  • Typical targetDimensions, entities
Partition overwrite
  • ReplacesWhole partitions
  • Needs a unique keyNo
  • Source deletes removedYes, per partition
  • Duplicate source keysLoaded twice
  • Atomic throughTable format commit
  • Typical targetLake fact tables

DELETE + INSERT: replace the slice the run owns

The simplest idempotent load deletes everything the run owns, then inserts it again from the source: DELETE FROM fact_orders WHERE order_date = :run_date followed by an INSERT ... SELECT with the same filter. A second run deletes what the first one wrote and writes the same rows back, so any number of runs ends in the same state. It needs no unique key, and it removes rows that disappeared from the source, which an upsert never does.

Both statements must commit together. If the delete commits and the insert fails, readers see an empty day until the retry lands; inside 1 transaction they see the old day or the new one, never neither. That guarantee is the atomicity in ACID, and it's why the pattern belongs on databases and warehouses with multi-statement transactions.

The delete filter and the insert filter must describe the same slice. A delete on order_date paired with an insert that filters on created_at in another time zone leaves rows at the edges of the day duplicated or missing, and the bug only shows on the days those edges hold data.

Idempotent upsert with MERGE or INSERT ON CONFLICT

An upsert matches each source row to the target by a unique key, updates the row when it exists and inserts it when it does not. MERGE is the SQL standard form, and PostgreSQL's INSERT ... ON CONFLICT DO UPDATE is a single statement that, in the words of its documentation, guarantees an atomic insert or update outcome even under high concurrency. Rerun with the same source, it leaves every key with the same values. That makes it the pattern for dimension and entity tables, where each key should hold its latest version.

An upsert is idempotent only if the source holds at most 1 row per key. PostgreSQL raises a cardinality violation when 1 statement would affect the same row twice, and other engines either raise an error or let an arbitrary row win, which changes the result from run to run. Deduplicate the source first with ROW_NUMBER() ordered by a column that breaks every tie.

The values it writes also have to come from the source rather than the clock. updated_at = CURRENT_TIMESTAMP in the update branch changes every matched row on every rerun, so 2 runs never leave the same table. Carry the source's own change timestamp, and add a condition such as WHEN MATCHED AND t.email IS DISTINCT FROM s.email so unchanged rows are left alone and a rerun writes nothing.

An upsert only adds and changes rows, so a customer deleted in the source stays in the target forever. Handle deletes with a soft-delete flag from change data capture, or reload the whole table or partition when the source cannot report deletions.

The same idempotent load in PostgreSQL, Snowflake and Spark

SQLload_fact_orders.sql
BEGIN;

DELETE FROM fact_orders
WHERE order_date = :run_date;

INSERT INTO fact_orders (order_id, order_date, customer_id, amount)
SELECT order_id, order_date, customer_id, amount
FROM staging_orders
WHERE order_date = :run_date;

COMMIT;

Partition overwrite in Spark: static vs dynamic

On a data lake the idempotent write is an overwrite of the partitions the run owns: rewrite order_date for the run's day and nothing else. It is cheaper than a row-level delete, and with a table format such as Delta Lake or Iceberg the swap commits atomically, so readers never see a half-written day.

Spark's default is where this goes wrong. With spark.sql.sources.partitionOverwriteMode at its default, static, an overwrite without an explicit partition spec replaces the whole table, not only the partitions in the DataFrame. Rerun day 3 of a table holding 3,650 rows across 3 days, with 1,320 rows for day 3, and the table ends with 1,320: days 1 and 2, 2,350 rows, are gone. The job is idempotent in the narrow sense, since a second run leaves the same 1,320 rows, but it has still deleted 2 days of data.

A table partitioned by day, overwritten with a DataFrame that holds only day 3: in static mode, the default, days 1 and 2 are deleted and only day 3's 1,320 rows remain; in dynamic mode day 1 keeps 1,200 rows, day 2 keeps 1,150 and day 3 is rewrittenA table partitioned by day, overwritten with a DataFrame that holds only day 3: in static mode, the default, days 1 and 2 are deleted and only day 3's 1,320 rows remain; in dynamic mode day 1 keeps 1,200 rows, day 2 keeps 1,150 and day 3 is rewritten

Setting the mode to dynamic replaces only the partitions the DataFrame contains, so the same rerun leaves 3,670 rows. You can set it per session or as a write option. The Delta Lake documentation covers both this setting and replaceWhere, which atomically overwrites the rows matching a predicate such as order_date = '...' and leaves every other row unchanged. Prefer replaceWhere when the slice is known, because it states what the run owns in the write itself.

Idempotent vs deterministic

The 2 words are often used as if they meant the same thing, and they do not. Deterministic describes a computation: the same input always produces the same output. Idempotent describes an effect on state: applying it again changes nothing. A pipeline step has both a computation and an effect, so it can have either property without the other.

A plain INSERT of a deterministic query computes the same rows every time and still doubles the table on a rerun: deterministic, not idempotent. An insert that skips keys already present and stamps each new row with now() is the opposite. Run it from an empty table twice and the timestamps differ, so it is not deterministic, yet a rerun against the loaded table changes nothing, so it's idempotent.

A 2 by 2 grid of write patterns: replace partition is deterministic and idempotent, a plain INSERT is deterministic but not idempotent, a row inserted once and stamped with now() is idempotent but not deterministic, and an append stamped with now() is neitherA 2 by 2 grid of write patterns: replace partition is deterministic and idempotent, a plain INSERT is deterministic but not idempotent, a row inserted once and stamped with now() is idempotent but not deterministic, and an append stamped with now() is neither

A pipeline you can trust on reruns needs both, and in that order of importance. Idempotency keeps retries from corrupting the target. Determinism makes a backfill reproduce what the first run produced, which is what lets you compare 2 runs or test a change against production output. It is also how you explain a number months later. For deterministic logic, sort on a stable key before ROW_NUMBER() and pin inputs to the run's interval; now() and random() have no place in the transformation.

Idempotency in streaming and exactly-once processing

Streaming systems deliver at least once by default: after a crash, a consumer resumes from its last committed offset and processes some records a second time. The sink therefore has to be idempotent, or every restart duplicates the records between the last commit and the crash.

Kafka's idempotent producer, enabled by default since Kafka 3.0, is often mistaken for the answer. It stops a producer's own retries from writing a message twice to a partition. It does not help when a job restarts and sends the same records again, and it does nothing for a consumer that writes to a database.

The durable fix is at the sink. Give each event a stable id from its source and upsert by that id, so a replayed event overwrites itself, or commit the output and the consumer's offsets in 1 transaction, which is what Kafka transactions and Flink's 2-phase commit sinks do. The first is simpler and works with any key-value or table sink; the second is what exactly-once means when a stream processor advertises it.

Side effects: emails, API calls and idempotency keys

A pipeline that only writes tables can replace what it wrote. A pipeline that sends an email, charges a card, posts to a webhook or calls a partner API cannot take anything back, so a retry repeats the side effect: 2 invoices, 2 alerts, 2 charges.

Give every side effect an idempotency key derived from what it is about, such as the invoice id plus the run's date, never from the attempt. Record the key in a table in the same transaction that marks the work done, and skip any key already recorded. Many payment and messaging APIs accept the key as a request header and discard a second request that carries it, which covers the gap between sending and recording.

Keep side effects out of the steps that transform data. A load that also emails customers can't be retried freely; a load followed by a separate send step that records its keys can.

How to test that a pipeline is idempotent

Run the step twice for the same interval and compare the target after each run. Row counts are the first check, but they miss changed values, so compare content as well: SELECT * FROM run_1 EXCEPT SELECT * FROM run_2 and the same in the other direction should both return 0 rows, excluding only columns that are allowed to differ, such as a run id.

Add a third run that fails on purpose halfway through, then a clean retry, and compare again. That is the case that breaks most loads in production, and it is the one a happy-path test never exercises.

In production, a uniqueness check on each table's natural key catches what tests miss. A load that doubled a partition fails it on the next run, long before the doubled revenue reaches a dashboard, and the fix is a rerun instead of a cleanup project.

Idempotent pipeline checklist

  • Every run is bound to 1 interval or batch id passed in by the scheduler, never to now() or the latest data
  • Every write replaces or merges the slice the run owns, and nothing appends
  • The delete and the insert commit in 1 transaction, or the table format commits the overwrite
  • Spark overwrites run in dynamic mode or with replaceWhere, never the default static mode
  • Upsert sources hold 1 row per key, chosen with a tie-breaking sort
  • Values written to the target come from the source or the run's interval, not the clock
  • Totals are recomputed from their inputs or summed from a ledger, never incremented
  • Every side effect carries an idempotency key recorded when the work is marked done
  • A test runs each step twice, and once after a failed attempt, and compares the target

How interviewers ask about idempotency

Few interviewers ask for the definition. They hand over a load such as INSERT INTO fact_events SELECT * FROM staging_events WHERE event_date = :date and ask what happens when it is retried, or describe a job that ran twice a day for a week and ask how to repair the table and stop it happening again. A strong answer names the failure (duplicates in every rerun partition), picks the write pattern that fits the table (DELETE + INSERT for a date-partitioned fact table, MERGE on the event id when one exists), dedupes the damage with ROW_NUMBER() over the natural key, and adds a uniqueness check so it can't recur silently.

In a design round the same idea comes up as "what happens when this step fails halfway" for every box on the whiteboard, and the data engineering system design round guide covers how to answer it across a whole pipeline. The data pipeline interview questions are the ones real loops ask about retries and late-arriving data, and the data pipeline practice problems make you design the load and its failure path end to end. For a longer set of worked idempotency answers, the idempotent pipelines interview guide walks through MERGE and partition overwrite, then tombstones for deletes, and the ETL interview questions test the same loads in their batch form.

Idempotency FAQ

What does idempotent mean?+
An operation is idempotent when applying it several times has the same effect as applying it once. In mathematics that is f(f(x)) = f(x). In data engineering it means a pipeline run for a given input leaves the target tables in the same state whether it runs once, is retried after a failure or is rerun a week later for a backfill.
What is an idempotent data pipeline?+
A pipeline in which every run reads a fixed slice of input, such as 1 day's partition or 1 batch id, and every write replaces or merges the rows that slice owns instead of appending them. Rerunning it for the same slice leaves the target exactly as a single clean run would, with nothing left over from a failed attempt.
Why must data pipelines be idempotent?+
Because every pipeline gets rerun. A job that fails partway through is retried, and a logic change means backfilling history. Sometimes a scheduler or a person starts the same run twice. Without idempotency each of those reruns silently duplicates or corrupts data, and someone has to clean it up by hand.
What is the difference between idempotent and deterministic?+
Deterministic describes a computation: the same input always produces the same output. Idempotent describes an effect: applying it again changes nothing. They are independent. A plain INSERT of a deterministic query isn't idempotent, because a rerun adds the rows again, while an insert that skips existing keys and stamps now() is idempotent but not deterministic.
Is an upsert idempotent?+
An upsert by a unique key is idempotent when the source has at most 1 row per key and the values it writes come from the source. It stops being idempotent when it sets a column such as updated_at to the current time on every run. When the source holds 2 rows for 1 key, the upsert either fails or picks an arbitrary winner.
Is INSERT OVERWRITE idempotent?+
Yes, for the data it replaces, but check what it replaces. In Spark's default static partition overwrite mode, overwriting a partitioned table without a partition spec replaces every partition, not only those in the new data. Set partitionOverwriteMode to dynamic, name the partition explicitly, or use Delta Lake's replaceWhere.
Does Airflow retry failed tasks by default?+
No. The default_task_retries setting is 0, so a task retries only when the DAG or the task sets retries. Teams usually do set it, which is why the Airflow documentation asks for tasks that produce the same result on every rerun.
Does exactly-once delivery make a pipeline idempotent?+
No. Kafka's idempotent producer, on by default since Kafka 3.0, stops a producer's own retries from writing a message twice. A job that's rerun and sends the same records again still creates duplicates unless the consumer or the sink deduplicates by a key.
02 / Why practice

The candidate who gets the offer

  1. 01

    Reading a solution is not the same as writing one

    Every engineer who has frozen on a query they had read a dozen times knows the gap. The only preparation that closes it is producing the answer yourself, under time, before the interview does it for you

  2. 02

    76% of hiring managers reject on the coding task, not the resume

    From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice

  3. 03

    System design comes down to the calls you defend out loud

    Ingestion, batch vs streaming, the bronze/silver/gold layers, idempotency, backfill and replay. Sketching the pipeline and naming the failure modes is the signal, not the boxes

Related guides