Skip to main content
Data Engineering beginner Lesson 3 of 10

Idempotency and Safe Retries

Every pipeline gets run twice. Four patterns that make that harmless — overwrite, delete-insert, merge, and deduplication on a natural key.

A pipeline step will run twice. Not might — will: a task that times out after committing, a worker that dies before reporting success, a retried DAG, an overlapping backfill. The question is only whether the second run does damage.

Setup

import duckdb
con = duckdb.connect("bookshop.duckdb")

con.execute("""
    create or replace table incoming as
    select * from (values
        (1001, 1, date '2026-01-04', 'completed', 25.50),
        (1002, 2, date '2026-01-04', 'completed', 12.00),
        (1003, 1, date '2026-01-04', 'returned',  40.00)
    ) t(order_id, customer_id, ordered_at, status, amount)
""")

The default is wrong

con.execute("create or replace table orders as select * from incoming where false")

def load_append():
    con.execute("insert into orders select * from incoming")

load_append()
load_append()

print(con.execute("select count(*) as rows, count(distinct order_id) as ids from orders").df().to_string(index=False))
 rows  ids
    6    3

Six rows, three orders. Nothing failed and nothing warned; every downstream sum is now double. This is the most common data bug there is, and it is created by the most obvious way to write a load.

Pattern 1: full overwrite

def load_overwrite():
    con.execute("create or replace table orders as select * from incoming")

load_overwrite()
load_overwrite()
print(con.execute("select count(*) as rows from orders").df().to_string(index=False))
 rows
    3

The simplest correct answer, and the right one whenever the source is small enough to reload in full — reference data, dimension tables, anything under a few million rows. It stops being viable when a full reload costs hours, and it cannot express “yesterday is final, today is still changing”.

Pattern 2: delete-insert by partition

con.execute("create or replace table orders as select * from incoming where false")

def load_partition(run_date):
    con.execute("begin transaction")
    con.execute("delete from orders where ordered_at = ?", [run_date])
    con.execute("insert into orders select * from incoming where ordered_at = ?", [run_date])
    con.execute("commit")

load_partition("2026-01-04")
load_partition("2026-01-04")
load_partition("2026-01-04")

print(con.execute("select ordered_at, count(*) from orders group by 1").df().to_string(index=False))
 ordered_at  count_star()
 2026-01-04             3

Three runs, three rows. The step deletes the slice it is about to write, so re-running replaces rather than adds — and it only touches one day, so a backfill of March does not disturb April.

Two things make it correct. The transaction means a crash between the delete and the insert rolls back rather than leaving an empty partition. And the delete predicate must match the insert exactly:

def load_broken(run_date):
    con.execute("delete from orders where ordered_at = ?", [run_date])
    con.execute("insert into orders select * from incoming")   # ← all dates, not one

load_broken("2026-01-04")
load_broken("2026-01-04")
print(con.execute("select count(*) as rows, count(distinct order_id) as ids from orders").df().to_string(index=False))
 rows  ids
    6    3

Duplicates again. The delete removed one day and the insert wrote everything, so any row outside that day accumulates. Whenever a delete-insert step duplicates, this mismatch is why.

Pattern 3: merge on a natural key

Delete-insert assumes rows belong to the partition being written. Late-arriving data breaks that — an order from Monday that turns up on Wednesday:

con.execute("""
    create or replace table incoming as
    select * from (values
        (1003, 1, date '2026-01-04', 'refunded',  40.00),   -- status changed
        (1007, 3, date '2026-01-04', 'completed', 18.40)    -- late arrival
    ) t(order_id, customer_id, ordered_at, status, amount)
""")

def load_merge():
    con.execute("""
        merge into orders t
        using incoming s on t.order_id = s.order_id
        when matched then update set status = s.status, amount = s.amount
        when not matched then insert values
            (s.order_id, s.customer_id, s.ordered_at, s.status, s.amount)
    """)

load_merge()
load_merge()

print(con.execute("select order_id, status, amount from orders order by order_id").df().to_string(index=False))
 order_id    status  amount
     1001 completed   25.50
     1002 completed   12.00
     1003  refunded   40.00
     1007 completed   18.40

Run it any number of times and the result is the same: 1003 updated, 1007 inserted once. MERGE is the general answer — it is what dbt’s incremental models, Delta Lake, Snowflake and BigQuery all use underneath.

The requirement is a genuine natural key. If order_id is not unique in the source, the merge either fails or picks a row arbitrarily:

con.execute("insert into incoming values (1007, 3, date '2026-01-04', 'returned', 18.40)")
load_merge()
duckdb.duckdb.InvalidInputException: MERGE with multiple source rows matching
the same target row is not allowed

A clear error rather than a coin flip. Deduplicate the source first:

con.execute("""
    create or replace table incoming_deduped as
    select * exclude (rn) from (
        select *, row_number() over (partition by order_id order by ordered_at desc) as rn
        from incoming
    ) where rn = 1
""")

Pattern 4: deduplicate on read

Sometimes you cannot control the write — an append-only event log, or a Kafka sink that guarantees at-least-once. Then the table holds duplicates by design and the view removes them:

con.execute("""
    create or replace table order_events as
    select * from (values
        (1001, 'created',   timestamp '2026-01-04 09:14:02', 1),
        (1001, 'created',   timestamp '2026-01-04 09:14:02', 1),   -- producer retry
        (1001, 'completed', timestamp '2026-01-04 09:18:41', 2),
        (1002, 'created',   timestamp '2026-01-04 09:20:00', 1)
    ) t(order_id, event, occurred_at, seq)
""")

con.execute("""
    create or replace view current_orders as
    select order_id, event, occurred_at
    from (
        select *, row_number() over (
            partition by order_id order by seq desc, occurred_at desc
        ) as rn
        from order_events
    )
    where rn = 1
""")

print(con.execute("select * from current_orders order by order_id").df().to_string(index=False))
 order_id     event         occurred_at
     1001 completed 2026-01-04 09:18:41
     1002   created 2026-01-04 09:20:00

The duplicate created event is still on disk and invisible to every reader. The ordering column matters — seq is monotonic per order, so a duplicate delivered late cannot overwrite a newer state. Ordering by wall-clock arrival time instead would.

Choosing

PatternCostHandles late dataUse when
full overwritereload everythingyessmall or slowly changing sources
delete-insertone partitionnolarge, partitioned by date, no late arrivals
mergematched rowsyesthe general case
dedup on readquery timeyesappend-only sources you do not control

Side effects are the hard part

Table writes are the easy half. A step that also sends an email, calls an API or drops a file is not idempotent just because its table write is:

con.execute("""
    create table if not exists sent_notifications (
        idempotency_key varchar primary key,
        order_id bigint,
        sent_at timestamp
    )
""")

def notify(order_id, run_date):
    key = f"order-shipped:{order_id}:{run_date}"
    already = con.execute(
        "select count(*) from sent_notifications where idempotency_key = ?", [key]
    ).fetchone()[0]
    if already:
        print(f"skip {key}")
        return
    print(f"POST /notifications {key}")
    con.execute("insert into sent_notifications values (?, ?, now())", [key, order_id])

notify(1001, "2026-01-04")
notify(1001, "2026-01-04")
notify(1001, "2026-01-05")
POST /notifications order-shipped:1001:2026-01-04
skip order-shipped:1001:2026-01-04
POST /notifications order-shipped:1001:2026-01-05

The key is derived from the data and the run, not generated randomly — so the second attempt computes the same key and recognises itself. Most payment and messaging APIs accept an Idempotency-Key header for exactly this; when one does not, the local ledger above is the fallback.

A checklist

Before shipping any pipeline step, answer these:

  1. What happens if this runs twice? Run it twice and check the row count.
  2. What happens if it dies halfway? Is the write in one transaction?
  3. What is the natural key? If there is not one, you cannot merge or deduplicate.
  4. What happens on a backfill? Does re-running March touch April?
  5. What side effects fire? Are they keyed on something stable?

Practice

1. Run an appending load twice and count the rows.
 rows  ids
    6    3

No error, no warning, every downstream number doubled. This is the failure to internalise — the pipeline reported success both times.

2. Make the delete predicate differ from the insert.
 rows  ids
    6    3

Delete one day, insert all days, and everything outside that day accumulates. Keeping the two predicates literally identical — ideally the same variable — is the fix.

3. Merge with a duplicated key in the source.
duckdb.duckdb.InvalidInputException: MERGE with multiple source rows matching
the same target row is not allowed

A loud failure rather than an arbitrary winner. Deduplicate before the merge with row_number() and an explicit ordering column.

4. Add an idempotency key to a side effect and call it twice.
POST /notifications order-shipped:1001:2026-01-04
skip order-shipped:1001:2026-01-04

Derive the key from the data, never from uuid4() — a random key is different on the retry, which is precisely when you need it to be the same.

Next: batch pipelines — scheduling, windows, and the boundary between runs.

Frequently Asked Questions

What does idempotent mean for a data pipeline?
Running the step twice produces the same result as running it once. That property is what makes retries, backfills and manual re-runs safe — and without it every one of those operations is a potential data corruption.
Why not just make sure a pipeline never runs twice?
Because you cannot. A task times out after writing, a worker dies mid-commit, someone re-runs a failed DAG, a backfill overlaps a scheduled run. At-least-once is what distributed systems actually give you, so the write has to tolerate it.
What is the difference between delete-insert and merge?
Delete-insert removes a whole partition and rewrites it, which is simple and fast when data arrives partitioned by date. `MERGE` matches on a key and updates or inserts per row, which handles late-arriving records that belong to an already-written partition.
How do I make writing to an external API idempotent?
Send a stable idempotency key derived from the record — the natural key plus the run date — and have the receiver reject duplicates. If the API has no such support, keep a local table of what you have already sent and check it before each call.