| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Two tables — maybe two entirely different databases — one question: are these actually the same rows?
duck_diff is a DuckDB extension that diffs two relations off a primary key, per row and per column. Every key gets a verdict (identical / different / left_only / right_only), a JSON summary of exactly which columns changed (diff_data), and typed <col>_left / <col>_right / <col>_diff_status columns you can filter and compute on. Composite keys, column subsets, and cross-engine tolerances included.
Because each side is just a query string, the two relations can live in different systems — Postgres, MySQL, ClickHouse, BigQuery, Snowflake, Iceberg, Delta, MongoDB, plain Parquet/CSV files, anything DuckDB can reach — and everything runs locally in your DuckDB process; your data never leaves your pond.
And since the verdict is deterministic (n_total = n_identical, true or false), it's a validation step Claude can run in loops (e.g. with Claude Code's /goal): safely refactor a model, optimize a slow query, or transpile SQL to another dialect, checking its own correctness after every change and stopping only when the diff comes back clean.
Website & recipe builder · Function reference · Runnable demos · AI-assisted migration
INSTALL duck_diff FROM community; -- one time, on stock DuckDB
LOAD duck_diff;Signed per-platform release binaries and source builds: docs/DISTRIBUTION.md · docs/DEVELOPMENT.md.
CREATE TABLE users_v1 AS SELECT * FROM (VALUES
(1, 'Ada', 'ada@x.com', 100),
(2, 'Linus', 'linus@x.com', 50),
(3, 'Grace', 'grace@x.com', 75)
) t(id, name, email, credits);
CREATE TABLE users_v2 AS SELECT * FROM (VALUES
(1, 'Ada', 'ada@x.com', 120), -- credits changed
(2, 'Linus', 'linus@x.com', 50), -- unchanged
(4, 'Mike', 'mike@x.com', 10) -- new (id 3 removed)
) t(id, name, email, credits);
SELECT id, diff_status, diff_data, credits_left, credits_right, credits_diff_status
FROM table_diff('FROM users_v1', 'FROM users_v2', pk := 'id') ORDER BY id;┌────┬─────────────┬──────────────────────────────────────┬──────────────┬───────────────┬─────────────────────┐
│ id │ diff_status │ diff_data │ credits_left │ credits_right │ credits_diff_status │
├────┼─────────────┼──────────────────────────────────────┼──────────────┼───────────────┼─────────────────────┤
│ 1 │ different │ {"credits":{"left":100,"right":120}} │ 100 │ 120 │ different │
│ 2 │ identical │ NULL │ 50 │ 50 │ identical │
│ 3 │ left_only │ NULL │ 75 │ NULL │ left_only │
│ 4 │ right_only │ NULL │ NULL │ 10 │ right_only │
└────┴─────────────┴──────────────────────────────────────┴──────────────┴───────────────┴─────────────────────┘
Take SELECT * for every column expanded, or project just the slice your use case needs. Two companions round it out: schema_diff(left, right) compares column names and types without reading a row, and table_diff_summary(…) returns one row of counts and percentages — in sync means everything lands in n_identical:
SELECT n_total = n_identical AS in_sync
FROM table_diff_summary('FROM users_v1', 'FROM users_v2', pk := 'id'); -- falseEach relation argument is a query string, so each side can point anywhere. Use dollar-quoting for nested quotes, and the native pass-through functions (postgres_query, mysql_query, mssql_scan, bigquery_query, snowflake_query) so the remote system runs your SQL in its own dialect and ships back only the rows you asked for:
SELECT * FROM table_diff(
$$ FROM postgres_query('pg', 'SELECT id, email, plan FROM public.customers') $$,
-- clickhouse_query is a three-line macro over ClickHouse's HTTP interface — see demo/
$$ FROM clickhouse_query('SELECT id, email, plan FROM appdb.customers FINAL
WHERE _peerdb_is_deleted = 0') $$,
pk := 'id',
require_matching_columns := false,
upcast_types := true, -- reconcile the two type systems
timestamp_precision := 'second' -- drop precision lost in transit
);Per key, this catches what row counts can't: CDC validation (ClickPipes/PeerDB, Debezium, Fivetran — stale rows, missed deletes, not-yet-synced inserts), replica integrity (pt-table-checksum, but row-by-row), migration/ELT parity (did every row land intact?), and snapshot drift between points in time.
Ready-to-run recipes live in demo/ — MySQL ↔ read replica, MySQL ↔ BigQuery, Postgres ↔ Snowflake, ClickHouse ↔ Parquet on S3, Iceberg ↔ DuckLake, Postgres ↔ Amazon S3 Tables, and the Postgres ↔ ClickHouse CDC audit — with credentials via env vars, a docker-compose playground seeded with intentional drift, interactive HTML reports, and one-file uv Python scripts (diff_report.py, mysql_bigquery_etl.py). Or point-and-click a recipe for your pair on the website.
LLMs translate SQL between dialects well and can't tell when they got it right — the diff can. Freeze a golden snapshot, convert one section at a time, accept only when the diff comes back 100% identical, and feed the drifted rows back on failure. The full workflow, a copy-paste prompt, and ready-made /goal phrasings: docs/ai-assisted-migration.md.
It ships as Claude Code skills in .claude/skills/ — sql-migrate (dialect conversion), etl-check (sync audits), and sql-optimize (below) — auto-loaded in any Claude Code session opened in this repo; copy a folder to ~/.claude/skills/ to use it everywhere.
The same gate turns performance tuning into a safe search: rewrite → time it → diff it → keep the rewrite only if it's faster AND the diff says identical → repeat.
SELECT n_total = n_identical AS accepted
FROM table_diff_summary('FROM golden', 'FROM candidate', pk := ['origin', 'rnk']);On a public 231k-row dataset, a quadratic self-join rewritten as a window function went 17.0 s → 0.01 s (~1700×) with the diff proving all 177 result rows byte-identical — the worked example is on the website, and the sql-optimize skill runs the loop autonomously.
The acceptance queries you finish with double as regression tests in CI: examples/ shows the sqllogictest pattern, runnable with nothing but the duckdb CLI.
| Function | Returns | Purpose |
|---|---|---|
| table_diff(left, right, pk := …) | table | one row per key: key column(s), diff_status, diff_data, expanded per-column values |
| table_diff_summary(left, right, pk := …) | one row | counts (and percentages) per status |
| schema_diff(left, right) | table | per-column name/type comparison: column_name, left_type, right_type, status |
Comparison is NULL-safe: NULL equals NULL. The full reference — every parameter (columns, ignore, context, prefix, the tolerance flags, cross-type comparison), output shapes, recipes, and performance/caching notes — is in docs/functions.md.
git clone --recurse-submodules https://github.com/avaitla/duck_diff
cd duck_diff
GEN=ninja make # builds a duckdb shell with duck_diff loaded
build/release/test/unittest "test/sql/*" # run the SQL test suiteDetails (loadable binary, prerequisites, using a local build): docs/DEVELOPMENT.md. Cutting a release: docs/DISTRIBUTION.md, with notes accumulated in RELEASE_NOTES.md.
MIT. Bundles DuckDB, which is also MIT-licensed.
| Back | FazBrowse Home | New Git URL |