Diff two tables by key, from the command line. Point it at two CSV / TSV /
JSON / JSONL files, name the key column, and get exactly what changed — rows
added, rows removed, and per-field changes — immune to row order and to
volatile columns like updated_at. Colored output for your terminal, Markdown
for PR comments, JSON for machines, and a meaningful exit code for CI.
Built on rowset-diff; the only
runtime dependency.
npm i -g table-diff # or: npx table-diff …Line-based diff/git diff is the wrong tool for tabular data: re-export the
same table with a different ORDER BY and every line "changed". Generic JSON
diff tools compare arrays by position and have the same problem. table-diff
matches rows by a key you name, so a reorder is silence and a real change
is a one-line finding.
$ table-diff examples/orders.before.csv examples/orders.after.csv --key order_id --ignore updated_at
examples/orders.before.csv → examples/orders.after.csv key: order_id
- order_id=A-1003 customer=Chris, total=50.00, status=refunded, updated_at=2026-07-02T08:00:00Z
+ order_id=A-1005 customer=Eve, total=75.25, status=pending, updated_at=2026-07-03T11:00:00Z
~ order_id=A-1004
total: 320.75 → 299.99
+1 added -1 removed ~1 changed 2 unchangedThe after-file was reordered and every updated_at changed — none of that
appears. Only the dropped order, the new order, and the one real price change.
table-diff <before> <after> --key <columns>
<before> and <after> are file paths, or - for stdin (one side only).
The format is inferred from the extension (.csv, .tsv/.tab, .json,
.jsonl/.ndjson); override with --input-format.
| Option | Default | |
|---|---|---|
-k, --key <cols> |
key column(s); comma-separate for a composite key | required |
--ignore <cols> |
columns to exclude from comparison (e.g. updated_at) |
— |
--fields <cols> |
compare only these columns | — |
-f, --format <fmt> |
pretty | markdown | json | summary |
pretty |
--input-format <fmt> |
csv | tsv | json | jsonl |
by extension |
--duplicates <mode> |
throw | keep-first | keep-last when a key repeats |
throw |
--limit <n> |
rows shown per section; 0 = unlimited |
20 |
-q, --quiet |
print nothing; the exit code is the answer | — |
--color / --no-color |
force colors on/off (NO_COLOR respected) |
auto |
Exit codes (GNU diff convention, so it drops straight into CI):
0 tables match · 1 differences found · 2 error.
Guard data files in CI — fail the build when a committed dataset drifts:
table-diff expected.csv actual.csv --key id --quiet || exit 1PR comment / job summary — Markdown tables, made for GitHub:
table-diff old.csv new.csv --key id -f markdown >> "$GITHUB_STEP_SUMMARY"**+1 added · −1 removed · ~1 changed · 2 unchanged** — key: `order_id`
#### ~ Changed (1)
| order_id | column | before | after |
| --- | --- | --- | --- |
| A-1004 | total | 320.75 | 299.99 |Compare a live query against a snapshot — pipe stdin with -:
psql "$DB" -c 'select * from users order by 1' --csv | table-diff - snapshot.csv --key user_idReconcile two systems on a composite key, ignoring churn:
table-diff erp.csv warehouse.csv --key store_id,sku --ignore synced_at,etagFeed the result to a script:
table-diff old.csv new.csv --key id -f json | jq '.changed[].fields'Everything the CLI does is exported as a library (ESM + CJS, no Node-specific imports — works in any JS runtime):
import { parseTable, tableDiff, formatMarkdown } from 'table-diff';
const before = parseTable(oldCsvText, 'csv'); // { columns, rows }
const after = parseTable(newCsvText, 'csv');
const result = tableDiff(before, after, { key: 'id', ignore: ['updated_at'] });
// { added, removed, changed, same, columns, columnsAdded, columnsRemoved, summary }
if (!result.same) console.log(formatMarkdown(result, { beforeLabel: 'old', afterLabel: 'new' }));parseCsv(text, { delimiter? })— strict RFC 4180 parser: quoted fields, embedded delimiters/newlines,""escapes, CRLF, BOM. Duplicate headers and ragged rows are errors, not guesses.parseTable(text, format)/parseJsonTable(text)/parseJsonl(text)/inferFormat(path)tableDiff(before, after, options)— header-aware wrapper aroundrowsetDiff: validates the key against both headers, tracks column-level adds/removes, and counts a summary. Acceptskey,ignore,fields,duplicates, and a customequals(e.g. numeric tolerance).formatPretty(result, opts)/formatMarkdown(result, opts)/formatJson(result, opts)/formatSummary(result)
- CSV values are strings.
table-diff old.csv new.jsonworks, but1(JSON number) ≠"1"(CSV string) — for cross-format diffs keep values as strings, or use the library with a customequals. (empty)vs(missing)in the output distinguish an empty string from a column that does not exist on that side. In--format json, missing values arenull.- Duplicate keys throw by default — a duplicate almost always means the
key you chose is not actually unique.
--duplicates keep-first|keep-lastopts into collapsing. - Rows only in one file are reported whole;
--ignoreaffects which field changes count, not which rows exist.
.xlsxinput (optional dependency)- A GitHub Action wrapping
--format markdowninto a PR comment - Streaming for very large files
Publishing is automated. A pushed version tag triggers the Publish workflow,
which runs the tests and publishes to npm (with provenance):
npm version patch # bumps package.json + creates the vX.Y.Z tag
git push --follow-tags # pushes the commit and the tag → CI publishesPublishing uses npm Trusted Publishing (OIDC)
— no npm token is stored anywhere. Set up once via the package's Trusted
Publisher settings on npmjs.com (org hojoongdev, repo table-diff,
workflow publish.yml). Every push and PR is tested across Node 18 / 20 / 22
by the CI workflow.
MIT © Hojoong Kim