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. Copy-on-write rewrites every file that holds a changed row, so a handful of corrections spread over many files rewrites the whole table. Merge-on-read 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 compaction folds them in. Deleted positions are often stored as Roaring bitmaps, a few bytes per deleted row.
Using a local Parquet 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
Expect (a) to write hundreds of MB for about 1 MB of change, and (b) to show scan time growing with the number of unmerged deltas until you compact. Expect the Roaring bitmap to be a few kilobytes at most, against 80 KB for a raw bitmap.
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
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)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
| Knob · default | What 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
| Delta files | Delta size (MB) | Full scan |
|---|---|---|
| 1 | 0.032 | 25.9 ms |
| 10 | 0.036 | 26.3 ms |
| 100 | 0.102 | 28.8 ms |
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)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.