Skip to content
About
Main content

Idempotent versus append retries

Break it on purposeNeeds Python

Problem

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 (delete its rows, then insert, in one transaction) is , 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 inside a transaction: after a crash, a retry, and then a rerun of the same interval, how many rows are there?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    idempotent-versus-append-retries.py59 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--rows100 Rows per day.
--seed0 Random seed.

Our run

Crash, retry, rerun

Crash, retry, rerun
LoadAfter crashAfter retryAfter rerunTotal after retryTotal after rerun
append, in a transaction01002001,0002,000
append, no transaction501502501,5002,500
overwrite, in a transaction01001001,0001,000
overwrite, random amounts010010052.248
Reference runApple M2 · 8 cores · 8 GB
Output · uv run idempotent-versus-append-retries.py
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)
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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 ; 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.