Diffing Two Crawl Snapshots with DuckDB
Two crawl snapshots are two Parquet files, and almost every question worth asking about a crawl is a question about the difference between them: which pages appeared, which disappeared, which changed status, which got slower. That is a join, and it does not need a warehouse. This page is part of Storing & Versioning Crawl Artifacts in Cloud Storage and covers running the diff with DuckDB, in the same job that wrote the snapshot.
Environment isolation and dependency declaration
set -euo pipefail
export DIFF_CURR="/data/crawl/2026-07-31/pages.parquet"
export DIFF_PREV="/data/crawl/2026-07-30/pages.parquet"
export DIFF_OUT="/data/crawl/2026-07-31/diff.parquet"
export DIFF_LCP_TOLERANCE_MS=50 # below this, not a change
export DIFF_SCORE_TOLERANCE=0.5 # below this, not a change
/opt/audit/.venv/bin/pip install "duckdb==1.0.0"
The two tolerance variables are what separate a usable diff from one that reports every row as changed. Floating-point measurements differ slightly between runs for reasons that have nothing to do with the site, and an exact comparison surfaces all of it.
Implementation
-- /opt/audit/diff/snapshot_diff.sql
-- Diff two crawl snapshots. Joined on final_url, not the requested URL, so a
-- moving redirect target is a redirect finding rather than a page change.
INSTALL httpfs; LOAD httpfs; -- only needed for s3:// paths
WITH curr AS (SELECT * FROM read_parquet($curr_path)),
prev AS (SELECT * FROM read_parquet($prev_path)),
joined AS (
SELECT
COALESCE(c.final_url, p.final_url) AS url,
CASE WHEN p.final_url IS NULL THEN 'appeared'
WHEN c.final_url IS NULL THEN 'disappeared'
ELSE 'both' END AS presence,
c.status AS status_now, p.status AS status_before,
c.lcp_ms AS lcp_now, p.lcp_ms AS lcp_before,
c.composite AS score_now, p.composite AS score_before,
c.canonical_url AS canon_now, p.canonical_url AS canon_before,
c.indexable AS idx_now, p.indexable AS idx_before
FROM curr c FULL OUTER JOIN prev p USING (final_url)
)
SELECT *,
-- A change is only a change outside the measurement tolerance.
(status_now IS DISTINCT FROM status_before) AS status_changed,
(idx_now IS DISTINCT FROM idx_before) AS indexable_changed,
(canon_now IS DISTINCT FROM canon_before) AS canonical_changed,
(abs(COALESCE(lcp_now, 0) - COALESCE(lcp_before, 0)) > $lcp_tol) AS lcp_changed,
(abs(COALESCE(score_now, 0) - COALESCE(score_before, 0)) > $score_tol) AS score_changed
FROM joined
WHERE presence <> 'both'
OR status_now IS DISTINCT FROM status_before
OR idx_now IS DISTINCT FROM idx_before
OR canon_now IS DISTINCT FROM canon_before
OR abs(COALESCE(lcp_now, 0) - COALESCE(lcp_before, 0)) > $lcp_tol
OR abs(COALESCE(score_now, 0) - COALESCE(score_before, 0)) > $score_tol;
#!/usr/bin/env python3
# /opt/audit/diff/run.py — parameterised, so paths never reach the SQL as text.
from __future__ import annotations
import os
import duckdb
SQL = open("/opt/audit/diff/snapshot_diff.sql").read()
con = duckdb.connect()
con.execute("SET enable_progress_bar = false")
res = con.execute(SQL, {
"curr_path": os.environ["DIFF_CURR"],
"prev_path": os.environ["DIFF_PREV"],
"lcp_tol": float(os.environ.get("DIFF_LCP_TOLERANCE_MS", "50")),
"score_tol": float(os.environ.get("DIFF_SCORE_TOLERANCE", "0.5")),
}).arrow()
duckdb.from_arrow(res).write_parquet(os.environ["DIFF_OUT"])
print(con.execute(
"SELECT presence, count(*) FROM res GROUP BY 1 ORDER BY 2 DESC").fetchall())
The choices that matter:
FULL OUTER JOIN USING (final_url). A full outer join is what makes appeared and disappeared visible at all; joining on the requested URL instead would report a change every time a redirect destination moves, which is a redirect finding rather than a page change.IS DISTINCT FROMrather than<>. A null on either side compares as unknown with a plain inequality, so a page that gained or lost a canonical would silently not register as changed.- Tolerances applied per column, sized from the measurement. Fifty milliseconds on LCP and half a point on a composite are below anything a human would act on, and above the noise two runs produce on an unchanged page.
- Parameters bound rather than interpolated. Paths come from the environment and are passed as query parameters, so a path containing a quote cannot become SQL.
Verification and smoke test
set -euo pipefail
python3 /opt/audit/diff/run.py
# 1. The diff is smaller than either input — if it is not, something is wrong.
python3 -c "
import duckdb, os
d = duckdb.sql(f\"SELECT count(*) FROM read_parquet('{os.environ['DIFF_OUT']}')\").fetchone()[0]
c = duckdb.sql(f\"SELECT count(*) FROM read_parquet('{os.environ['DIFF_CURR']}')\").fetchone()[0]
assert d < c * 0.5, f'diff has {d} rows against {c} crawled — check tolerances and scope'
print(f'PASS: {d} changed rows out of {c}')"
# 2. Diffing a snapshot against itself must produce nothing.
DIFF_PREV="$DIFF_CURR" DIFF_OUT=/tmp/self.parquet python3 /opt/audit/diff/run.py
python3 -c "
import duckdb
n = duckdb.sql(\"SELECT count(*) FROM read_parquet('/tmp/self.parquet')\").fetchone()[0]
assert n == 0, f'self-diff produced {n} rows — a comparison is not stable'
print('PASS: self-diff is empty')"
Expected output is two PASS lines. The self-diff assertion is the cheapest possible correctness test and it catches the two most common defects at once: an exact float comparison, and a null-handling mistake that makes a column compare unequal to itself.
Failure modes
The diff is nearly as large as the snapshot
Either the tolerances are too tight or the two snapshots were crawled under different scope configurations. Run the self-diff first to eliminate the tolerance explanation, then compare the scope config recorded in each snapshot — which is the argument for recording it in the artifact rather than only in the job that produced it.
Appeared and disappeared counts are both large and roughly equal
A canonicalisation change is moving URLs from one spelling to another, so the same page appears under a new address and disappears under the old one. Normalise the join key the same way in both snapshots, and treat the normalisation change itself as the finding.
Memory grows on very large snapshots
A full outer join materialises both sides. Above roughly ten million rows per snapshot, project the columns you need before joining rather than selecting everything, and set SET memory_limit explicitly so the query spills to disk rather than being killed — the same discipline as bounding crawl worker memory.
FAQ
Why join on the final URL rather than the requested one?
Because the requested URL and the page are different things when a redirect is involved. Joining on the requested URL means that every time a redirect destination changes, the page reads as changed even though its content, status and score are identical — and on an estate with active redirect maintenance that is thousands of false changes per run. Joining on the resolved address compares pages with pages, and the hop itself becomes a separate redirect finding.
Why does a diff need per-column tolerances?
Because floating-point measurements differ slightly between runs for reasons unrelated to the site — measurement precision, rounding, and genuine sub-threshold variation. An exact comparison reports every row as changed, which makes the diff useless and hides the changes that matter inside noise. A tolerance sized from the measurement precision, such as fifty milliseconds on a paint timing, filters that out while still catching anything a human would act on.
Why is a self-diff the first thing to assert?
Because it is free and it catches the two defects that break a diff most often. Diffing a snapshot against itself must produce zero rows; if it does not, either a float column is being compared exactly, or a nullable column is comparing unequal to itself because a plain inequality treats null comparisons as unknown. Both produce a diff that looks plausible on real data and is entirely artefact, and both are invisible without this check.
Related
- Storing & Versioning Crawl Artifacts in Cloud Storage — the parent guide covering snapshot layout, checksums and drift-detection queries
- Parquet vs JSONL for Crawl Artifact Storage — why the columnar format is what makes this diff cheap
- Tracking Metric Trends Across Release Cycles — attributing the changes this diff surfaces to the release that caused them