Verify a rewrite
Break it on purposeNeeds Python with DuckDB
Problem
A compaction or sort rewrites every row of a table, so it is an opportunity to lose or duplicate rows. A row count catches lost rows but not a row replaced by a copy of another: the count still matches. An order-independent fingerprint, the sum of a hash of every row, catches both, because it depends on every row's content and not on the order rows are written in.
Take a Parquet dataset (1 million rows in 50 files), run a compaction into one sorted file, and compare count(*) and sum(hash(...)) before and after. Then deliberately corrupt the output: drop one row, and replace one row with a duplicate of another (the count stays the same).
The question. Which checks catch each corruption?
Predict
The fingerprint catches the corruption while the row count alone might not (one row replaced with a duplicate of another keeps the count).
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
verify-a-rewrite.py # /// script # requires-python = ">=3.10" # dependencies = ["duckdb"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Verify a rewrite. A Parquet dataset of 1 million rows in 50 files is compacted into one sorted file. count(*) and an order-independent fingerprint, sum(hash(row)), are compared before and after; then the output is corrupted twice: one row dropped, and one row replaced by a copy of another (the count stays right). """ import argparse, json, os, shutil, tempfile import duckdb p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=1_000_000, help="Rows.") p.add_argument("--files", type=int, default=50, help="Input files.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() d = tempfile.mkdtemp(); c = duckdb.connect(); c.execute("SELECT setseed(0.2)") c.execute(f"CREATE TABLE src AS SELECT i AS id, (random() * 1000)::INT AS amount, 'c' || (i % 977) AS customer FROM range({args.rows}) t(i)") for i in range(args.files): c.execute(f"COPY (SELECT * FROM src WHERE id % {args.files} = {i}) TO '{d}/in-{i:03d}.parquet' (FORMAT parquet)") FP = "SELECT count(*), sum(hash(id, amount, customer)) FROM read_parquet('{p}')" before = c.execute(FP.format(p=f"{d}/in-*.parquet")).fetchone() c.execute(f"COPY (SELECT * FROM read_parquet('{d}/in-*.parquet') ORDER BY customer, id) TO '{d}/compacted.parquet' (FORMAT parquet)") cases = [("compaction (sorted, one file)", f"SELECT * FROM read_parquet('{d}/compacted.parquet')"), ("one row dropped", f"SELECT * FROM read_parquet('{d}/compacted.parquet') WHERE id <> 12345"), ("one row replaced by a copy of another", f"SELECT * FROM read_parquet('{d}/compacted.parquet') WHERE id <> 12345 UNION ALL SELECT * FROM read_parquet('{d}/compacted.parquet') WHERE id = 54321")] rows = [] for name, sql in cases: c.execute(f"COPY ({sql}) TO '{d}/out.parquet' (FORMAT parquet)"); after = c.execute(FP.format(p=f"{d}/out.parquet")).fetchone() rows.append({"output": name, "count_matches": "yes" if after[0] == before[0] else "no", "fingerprint_matches": "yes" if after[1] == before[1] else "no"}) shutil.rmtree(d, ignore_errors=True) for r in rows: print(f"{r['output']:<40} count matches: {r['count_matches']:<3} fingerprint matches: {r['fingerprint_matches']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"checks": 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 verify-a-rewrite.py uv run verify-a-rewrite.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows1,000,000 | Rows. |
| --files50 | Input files. |
Our run
Count and fingerprint after each rewrite
| Output | Count matches | Fingerprint matches |
|---|---|---|
| compaction (sorted, one file) | yes | yes |
| one row dropped | no | no |
| one row replaced by a copy of another | yes | no |
compaction (sorted, one file) count matches: yes fingerprint matches: yes
one row dropped count matches: no fingerprint matches: no
one row replaced by a copy of another count matches: yes fingerprint matches: noWhat we found
The clean compaction matched on both checks. With one row dropped, both the count (no) and the fingerprint (no) caught it. With one row replaced by a copy of another, the count still matched (yes) and only the fingerprint caught it (no).
Read it
A rewrite should be verified with something that covers content, not just cardinality, and that ignores order, since reordering is the point of the rewrite. The fingerprint costs one extra scan of each side, which is cheap next to a silent change to every downstream number.