Skip to content
About
Main content

Copy-on-write versus delete vector

Measure your machineNeeds Python with DuckDB, PyArrow

Problem

A table format can apply a row change in two ways. rewrites every file that holds a changed row, so a handful of corrections spread over many files rewrites the whole table. writes the changes to small delta files (deleted positions, new rows) and merges them at read time, so writes are tiny and every scan pays a little more for each unmerged delta until a folds them in. Deleted positions are often stored as , a few bytes per deleted row.

Using a local table of 640,000-row files, apply 5,000 random row corrections two ways. (a) Copy-on-write: rewrite every touched file. (b) Merge-on-read: write one small delta file per batch of corrections. Measure bytes written and the time of a full scan after 1, 10 and 100 batches of deltas. Then build a Roaring bitmap of 1,000 deleted positions in a 640,000-row file and print its serialized size.

The question. How many bytes does each approach write, how much do unmerged deltas cost a scan, and how small is a deletion bitmap?


Predict

Copy-on-write, 5,000 random corrections: how many bytes are written per byte changed?

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.

    copy-on-write-versus-delete-vector.py76 linesDownload
    View the code
    copy-on-write-versus-delete-vector.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["duckdb", "numpy", "pyarrow", "pyroaring"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Copy-on-write against delete vectors.
    
    A local Parquet table of 640,000-row files. 5,000 random row corrections are
    applied two ways: (a) copy-on-write, rewriting every file a correction
    touches; (b) merge-on-read, writing one small delta file (deleted positions
    plus the corrected rows) per batch of corrections. Bytes written by each, and
    the time of a full scan as unmerged delta files accumulate (1, 10, 100). Then
    a Roaring bitmap of 1,000 deleted positions in one file.
    """
    import argparse, json, os, shutil, tempfile, time
    import duckdb, numpy as np, pyarrow as pa, pyarrow.parquet as pq
    from pyroaring import BitMap
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--files", type=int, default=8, help="Data files.")
    p.add_argument("--rows-per-file", type=int, default=640_000, help="Rows per file.")
    p.add_argument("--corrections", type=int, default=5000, help="Row corrections.")
    p.add_argument("--deltas", default="1,10,100", help="Unmerged delta files to scan through.")
    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()
    
    rng = np.random.default_rng(args.seed); d = tempfile.mkdtemp(); N = args.files * args.rows_per_file
    for f in range(args.files):
        ids = np.arange(f * args.rows_per_file, (f + 1) * args.rows_per_file)
        pq.write_table(pa.table({"id": ids, "amount": rng.integers(1, 10_000, len(ids)), "note": np.char.add("n", (ids % 9973).astype(str))}),
                       os.path.join(d, f"base-{f}.parquet"))
    base_bytes = sum(os.path.getsize(os.path.join(d, x)) for x in os.listdir(d))
    fix = np.sort(rng.choice(N, args.corrections, replace=False))
    # (a) copy-on-write: every touched file is rewritten
    touched = sorted(set((fix // args.rows_per_file).tolist())); t = time.perf_counter(); cow_bytes = 0
    for f in touched:
        tb = pq.read_table(os.path.join(d, f"base-{f}.parquet")); amt = tb["amount"].to_numpy().copy()
        local = fix[(fix // args.rows_per_file) == f] - f * args.rows_per_file; amt[local] = 0
        out = os.path.join(d, f"cow-{f}.parquet"); pq.write_table(tb.set_column(1, "amount", pa.array(amt)), out); cow_bytes += os.path.getsize(out); os.remove(out)
    cow_s = time.perf_counter() - t
    # (b) merge-on-read: one small delta file per batch
    def write_deltas(k):
        for old in [x for x in os.listdir(d) if x.startswith("delta-")]: os.remove(os.path.join(d, old))
        total = 0
        for i, part in enumerate(np.array_split(fix, k)):
            path = os.path.join(d, f"delta-{i:04d}.parquet")
            pq.write_table(pa.table({"id": part, "amount": np.zeros(len(part), dtype=np.int64)}), path); total += os.path.getsize(path)
        return total
    c = duckdb.connect(); c.execute("SET threads = 4")
    SCAN = f"""SELECT count(*), sum(coalesce(dl.amount, b.amount)) FROM read_parquet('{d}/base-*.parquet') b
               LEFT JOIN read_parquet('{d}/delta-*.parquet') dl USING (id)"""
    rows = []
    for k in map(int, args.deltas.split(",")):
        mor_bytes = write_deltas(k)
        c.execute(SCAN).fetchall(); ts = []
        for _ in range(3):
            t = time.perf_counter(); c.execute(SCAN).fetchall(); ts.append(time.perf_counter() - t)
        rows.append({"delta_files": k, "delta_mb": mor_bytes / 2**20, "scan_s": min(ts)})
    for x in os.listdir(d):
        if x.startswith("delta-"): os.remove(os.path.join(d, x))
    t = time.perf_counter(); c.execute(f"SELECT count(*), sum(amount) FROM read_parquet('{d}/base-*.parquet')").fetchall(); plain = time.perf_counter() - t
    bm = BitMap(sorted(rng.choice(args.rows_per_file, 1000, replace=False).tolist()))
    summary = {"touched_files": len(touched), "files": args.files, "cow_mb": cow_bytes / 2**20, "cow_s": cow_s, "changed_mb": args.corrections * 16 / 2**20,
               "base_mb": base_bytes / 2**20, "plain_scan_s": plain, "roaring_bytes": len(bm.serialize()), "raw_bitmap_bytes": args.rows_per_file // 8}
    summary["cow_amplification"] = summary["cow_mb"] / summary["changed_mb"]
    shutil.rmtree(d, ignore_errors=True)
    print(f"copy-on-write: {len(touched)} of {args.files} files rewritten, {summary['cow_mb']:.0f} MB written for {args.corrections:,} corrections (~{summary['changed_mb']:.2f} MB of change)")
    for r in rows: print(f"merge-on-read, {r['delta_files']:>3} delta files ({r['delta_mb']:.2f} MB): full scan {r['scan_s']:.3f} s   (no deltas {plain:.3f} s)")
    print(f"Roaring bitmap of 1,000 positions in {args.rows_per_file:,} rows: {summary['roaring_bytes']:,} bytes (raw bitmap {summary['raw_bitmap_bytes']:,})")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"deltas": 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 copy-on-write-versus-delete-vector.py
    uv run copy-on-write-versus-delete-vector.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--files8 Data files.
--rows-per-file640,000 Rows per file.
--corrections5000 Row corrections.
--deltas1, 10, 100 Unmerged delta files to scan through.
--seed0 Random seed.

Our run

Merge-on-read: full scan against unmerged delta files

Merge-on-read: full scan against unmerged delta files
Delta filesDelta size (MB)Full scan
10.03225.9 ms
100.03626.3 ms
1000.10228.8 ms
Reference runApple M2 · 8 cores · 8 GB
Output · uv run copy-on-write-versus-delete-vector.py
copy-on-write: 8 of 8 files rewritten, 39 MB written for 5,000 corrections (~0.08 MB of change)
merge-on-read,   1 delta files (0.03 MB): full scan 0.026 s   (no deltas 0.004 s)
merge-on-read,  10 delta files (0.04 MB): full scan 0.026 s   (no deltas 0.004 s)
merge-on-read, 100 delta files (0.10 MB): full scan 0.029 s   (no deltas 0.004 s)
Roaring bitmap of 1,000 positions in 640,000 rows: 2,088 bytes (raw bitmap 80,000)
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · numpy 2.5.4 · pyarrow 26.0.0 · pyroaring 1.2.0 · run 2026-10-20

What we found

Copy-on-write rewrote 8 of 8 files, writing 39.4 MB for about 0.076 MB of changed values: 517 bytes written per byte changed, because 5,000 random corrections touch every file. Merge-on-read wrote 0.032 MB of deltas. Scans grew only slightly with unmerged deltas, from 25.9 ms with one delta file to 28.8 ms with 100, against 4.46 ms with none: the cost came from merging at all, not from the number of delta files, because DuckDB reads small files cheaply from local disk. A Roaring bitmap of 1,000 deleted positions took 2.09K bytes against 80K for a raw bitmap.


Read it

Copy-on-write puts the cost on writers and keeps reads simple; merge-on-read puts it on readers until a compaction. The choice follows the ratio of writes to reads, and merge-on-read always needs a compaction schedule, or the read penalty grows without limit.