Idempotent versus append retries
Break it on purposeNeeds Python
Problem
Orchestrators retry failed tasks and rerun old ones, so every task must be safe to run more than once. An append is not: a retry after a partial write, or any rerun, adds the rows again. Overwriting the task's own partition (delete its rows, then insert, in one transaction) is idempotent, provided the task computes the same rows each time; a task that reads a clock or a random number writes different data on every run, safely but differently.
Use SQLite. Write one task that loads a day's 100 rows with a simulated crash after half the rows. Run it as plain INSERT (append), in a transaction and without one, and as DELETE of the day's partition followed by INSERT in one transaction. Retry after the crash, then rerun once more. Then make the amount random.random().
The question. After a crash, a retry and a rerun, how many rows and what total does each loading pattern leave?
Predict
Append with crash and retry leaves 100 rows if the transaction rolls back, but without the transaction, the crash leaves 50 rows and the retry gives 150 (duplicates). Overwrite always gives 100. With random amounts an idempotent retry silently changes the data: determinism matters.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
idempotent-versus-append-retries.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Idempotent against append retries. One task loads a day's 100 rows into SQLite and crashes after half of them, then is retried. Four ways: append inside a transaction, append without one, overwrite (delete the day's partition, then insert) in one transaction, and overwrite with random amounts, to see an idempotent retry change the data. """ import argparse, json, random, sqlite3 p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=100, help="Rows per day.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) def case(mode, transactional, amount): db = sqlite3.connect(":memory:", isolation_level=None) db.execute("CREATE TABLE sales(day TEXT, store INT, amount REAL)") def load(day, crash=False): if transactional: db.execute("BEGIN") try: if mode == "overwrite": db.execute("DELETE FROM sales WHERE day=?", (day,)) for s in range(args.rows): if crash and s == args.rows // 2: raise RuntimeError("boom") db.execute("INSERT INTO sales VALUES (?,?,?)", (day, s, amount())) if transactional: db.execute("COMMIT") except RuntimeError: if transactional: db.execute("ROLLBACK") raise try: load("2025-03-01", crash=True) except RuntimeError: pass after_crash = db.execute("SELECT count(*), sum(amount) FROM sales").fetchone() load("2025-03-01") # the retry first = db.execute("SELECT count(*), sum(amount) FROM sales").fetchone() load("2025-03-01") # and one more, as a rerun would second = db.execute("SELECT count(*), sum(amount) FROM sales").fetchone() return after_crash[0], first, second rows = [] for label, mode, tx, amount in (("append, in a transaction", "append", True, lambda: 10.0), ("append, no transaction", "append", False, lambda: 10.0), ("overwrite, in a transaction", "overwrite", True, lambda: 10.0), ("overwrite, random amounts", "overwrite", True, random.random)): crash_rows, first, second = case(mode, tx, amount) rows.append({"load": label, "rows_after_crash": crash_rows, "rows_after_retry": first[0], "rows_after_rerun": second[0], "total_after_retry": round(first[1], 2), "total_after_rerun": round(second[1], 2), "rerun_changed_total": "yes" if round(first[1], 6) != round(second[1], 6) else "no"}) for r in rows: print(f"{r['load']:<30} after crash {r['rows_after_crash']:>3} rows; after retry {r['rows_after_retry']:>3} (total {r['total_after_retry']}); after a rerun {r['rows_after_rerun']:>3} (total {r['total_after_rerun']}, changed: {r['rerun_changed_total']})") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"loads": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run idempotent-versus-append-retries.py uv run idempotent-versus-append-retries.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows100 | Rows per day. |
| --seed0 | Random seed. |
Our run
Crash, retry, rerun
| Load | After crash | After retry | After rerun | Total after retry | Total after rerun |
|---|---|---|---|---|---|
| append, in a transaction | 0 | 100 | 200 | 1,000 | 2,000 |
| append, no transaction | 50 | 150 | 250 | 1,500 | 2,500 |
| overwrite, in a transaction | 0 | 100 | 100 | 1,000 | 1,000 |
| overwrite, random amounts | 0 | 100 | 100 | 52.2 | 48 |
append, in a transaction after crash 0 rows; after retry 100 (total 1000.0); after a rerun 200 (total 2000.0, changed: yes)
append, no transaction after crash 50 rows; after retry 150 (total 1500.0); after a rerun 250 (total 2500.0, changed: yes)
overwrite, in a transaction after crash 0 rows; after retry 100 (total 1000.0); after a rerun 100 (total 1000.0, changed: no)
overwrite, random amounts after crash 0 rows; after retry 100 (total 52.17); after a rerun 100 (total 47.97, changed: yes)What we found
Append in a transaction survived the crash (0 rows, then 100 after the retry) but doubled on the rerun (200). Without a transaction the crash left 50 rows and the retry made 150. Overwrite gave 100 rows every time. With random amounts, overwrite still gave 100 rows, but the total moved from 52.2 to 48 on the rerun.
Read it
A transaction makes one attempt atomic; it does not make the task idempotent. Idempotence comes from the write pattern (replace exactly the task's own output) and from determinism (compute the same output from the same input). The second is the one that fails without any error.