Find your own break-even point
Measure your machineNeeds Python with DuckDB
Problem
Incremental processing promises to touch only what changed, but it is not free. A full rebuild costs about S·c: every row at the cheap rate c of a sequential scan. An incremental run costs about Δ·S·c′ + F: the changed fraction Δ of the rows at the merge rate c′, which is a random-access write into existing state, plus a fixed overhead F. Incremental wins only while Δ < (1 − F/(S·c)) · c/c′. The merge penalty c′/c depends on the storage underneath, so the crossover is a property of your engine, not a universal number.
Create a table of 5 million rows in DuckDB or SQLite (a laptop is enough). Time a full rebuild (CREATE TABLE t2 AS SELECT ... FROM src). Then change a fraction Δ of the rows (1%, 5%, 10%, 20%, 40%) and time an incremental merge into a copy of the table (MERGE, or DELETE plus INSERT of the changed keys). Plot time against Δ.
The script runs both engines (DuckDB, a columnar engine, and SQLite, a row store keyed by id) and adds fractions of 0.1%, 0.5% and 70%.
The question. At what changed fraction does the incremental merge stop being faster than rebuilding the whole table, and does the answer change with the engine?
Predict
Expect the incremental time to rise roughly with Δ and cross the rebuild time somewhere in the single-digit to tens of percent. The exact point depends on your engine; the lab is about seeing that there is one.
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
find-your-own-break-even-point.py # /// script # requires-python = ">=3.10" # dependencies = ["duckdb"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Find your own break-even point. A full rebuild of a table against an incremental merge (DELETE the changed keys, INSERT their new rows) for a growing changed fraction, in DuckDB (a columnar engine) or SQLite (a row store keyed by id). """ import argparse, json, sqlite3, statistics, time import duckdb p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=5_000_000, help="Rows in the source table.") p.add_argument("--fractions", type=lambda s: [float(v) for v in s.split(",")], default=[0.001, 0.005, 0.01, 0.05, 0.10, 0.20, 0.40, 0.70], help="Changed fractions to merge, comma-separated.") p.add_argument("--engine", choices=["duckdb", "sqlite"], default="duckdb", help="Which engine holds the tables.") p.add_argument("--repeats", type=int, default=3, help="Runs per measurement; the median is reported.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() if args.engine == "duckdb": con = duckdb.connect() con.execute(f"CREATE TABLE src AS SELECT i AS id, random() AS v FROM range({args.rows}) t(i)") REBUILD = "CREATE TABLE t2 AS SELECT * FROM src" COPY = "CREATE OR REPLACE TABLE t2 AS SELECT * FROM src" DELTA = "CREATE OR REPLACE TABLE delta AS SELECT id, random() AS v FROM src WHERE random() < {frac}" run = con.execute else: # a row store: the target is keyed, as a merge needs con = sqlite3.connect(":memory:", isolation_level=None) con.execute("CREATE TABLE src (id INTEGER PRIMARY KEY, v REAL)") con.execute(f"WITH RECURSIVE r(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM r WHERE i < {args.rows - 1}) " "INSERT INTO src SELECT i, abs(random()) / 9.2e18 FROM r") REBUILD = "CREATE TABLE t2 (id INTEGER PRIMARY KEY, v REAL); INSERT INTO t2 SELECT * FROM src" COPY = "DROP TABLE IF EXISTS t2; CREATE TABLE t2 (id INTEGER PRIMARY KEY, v REAL); INSERT INTO t2 SELECT * FROM src" DELTA = ("DROP TABLE IF EXISTS delta; CREATE TABLE delta AS SELECT id, abs(random()) / 9.2e18 AS v FROM src " "WHERE abs(random()) / 9.2e18 < {frac}") run = lambda sql: con.executescript(sql) if ";" in sql else con.execute(sql) def median(fn): runs = [] for _ in range(args.repeats): prep = fn() t = time.perf_counter(); prep(); runs.append(time.perf_counter() - t) return statistics.median(runs) # Full rebuild: a new table from the source def rebuild(): run("DROP TABLE IF EXISTS t2") return lambda: run(REBUILD) full = median(rebuild) print(f"{args.engine}: full rebuild of {args.rows:,} rows: {full:.3f} s") # Incremental: a fresh copy, a delta of the changed rows, then DELETE + INSERT rows = [] for frac in args.fractions: def incremental(): run(COPY) run(DELTA.format(frac=frac)) def merge(): run("DELETE FROM t2 WHERE id IN (SELECT id FROM delta)") run("INSERT INTO t2 SELECT * FROM delta") return merge t = median(incremental) rows.append({"changed_pct": frac * 100, "incremental_s": t, "rebuild_s": full}) print(f"changed {frac:6.1%}: incremental {t:.3f} s ({'faster' if t < full else 'slower'} than the rebuild)") cross = next((r["changed_pct"] for r in rows if r["incremental_s"] >= full), None) if cross == rows[0]["changed_pct"]: print(f"incremental never won: even {cross:g}% changed is slower than rebuilding") else: print(f"incremental stops winning at about {cross:g}% changed" if cross else "incremental won at every fraction tried") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"merge": rows, "rebuild": [{"label": "full rebuild", "seconds": full}], "summary": [{"rebuild_s": full, "crossover_pct": cross if cross is not None else -1, "first_pct": rows[0]["changed_pct"], "first_s": rows[0]["incremental_s"]}]}}, 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 find-your-own-break-even-point.py uv run find-your-own-break-even-point.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows5,000,000 | Rows in the source table. |
| --fractions0.001, 0.005, 0.01, 0.05, 0.1, 0.2, 0.4, 0.7 | Changed fractions to merge, comma-separated. |
| --engineduckdb | sqlite (duckdb) | Which engine holds the tables. |
| --repeats3 | Runs per measurement; the median is reported. |
Our run
Data table
| Rows changed | Seconds (median of runs) |
|---|---|
| 0.1% | 9.89 ms |
| 0.5% | 10.9 ms |
| 1% | 12.7 ms |
| 5% | 28 ms |
| 10% | 37.6 ms |
| 20% | 53.5 ms |
| 40% | 84.6 ms |
| 70% | 92.9 ms |
duckdb: full rebuild of 5,000,000 rows: 0.017 s
changed 0.1%: incremental 0.010 s (faster than the rebuild)
changed 0.5%: incremental 0.011 s (faster than the rebuild)
changed 1.0%: incremental 0.013 s (faster than the rebuild)
changed 5.0%: incremental 0.028 s (slower than the rebuild)
changed 10.0%: incremental 0.038 s (slower than the rebuild)
changed 20.0%: incremental 0.054 s (slower than the rebuild)
changed 40.0%: incremental 0.085 s (slower than the rebuild)
changed 70.0%: incremental 0.093 s (slower than the rebuild)
incremental stops winning at about 5% changedWhat we found
Both engines show a crossover, at very different places. In DuckDB a full rebuild of 5 million rows took only 17.4 ms, a single vectorized copy of two columns, and the merge stopped winning at about 5% changed. In SQLite the rebuild took 942 ms, because every row goes into a keyed B-tree, and the merge kept winning up to about 40% changed. Both fall inside the chapter's "single-digit to tens of percent", at opposite ends of it.
That is the merge penalty at work. A columnar engine is built for whole-column scans and pays heavily for row-level changes, so its c′/c is large and its break-even sits low. A keyed row store pays little for a row-level change compared with rebuilding its index, so incremental keeps winning to a much larger Δ. A real rebuild usually also transforms the data (joins, aggregation), which raises c and moves the crossover up in both engines.
Read it
Full recompute costs S·c. Incremental costs Δ·S·c′ + F. Incremental wins when Δ < (1 − F/(S·c)) · c/c′. With a 20× merge penalty the crossover is near 5%. Below some absolute size, F makes incremental strictly worse.
The lab measures only the merge itself. In production F also includes offset tracking, state storage, capture infrastructure and the human work of keeping it honest, which all push the crossover further down. Measure your own Δ and your own c′/c before building an incremental pipeline; for many tables the cheaper and safer answer is a nightly rebuild.