Skip to content
About
Main content

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 F. Incremental wins only while Δ < (1 − F/(S·c)) · c/c′. The 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

In DuckDB, at what share of changed rows does the incremental merge stop beating a full rebuild?

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.

    find-your-own-break-even-point.py82 linesDownload
    View 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)
    
  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 find-your-own-break-even-point.py
    uv run find-your-own-break-even-point.py --help     # every knob
Knobs
Knob · defaultWhat 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

Incremental merge time against the changed fractionApple M2 · 8 cores · 8 GB
Engine
Data table
Incremental merge time against the changed fraction
Rows changedSeconds (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
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · run 2026-10-20 · 2 runs
Output · uv run find-your-own-break-even-point.py --engine duckdb
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% changed

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