Diff two CSV/TSV/JSON tables — and act on the difference.
cdiff is a hard fork of simonw/csv-diff
(Apache-2.0). Upstream answers "what changed?" in a form a human reads.
This fork also answers it in a form a pipeline can act on: composite keys,
a comparison policy, machine-readable cell-level records, and an exit-code
contract you can put in CI.
$ cdiff yesterday.csv today.csv --key id,region --ignore-case --numeric-tolerance 0.01
Same two files, twice. Strict mode reports 4 rows changed (9 cells), 1 added,
1 removed — including 980.50 vs 980.5 and Nordic US vs nordic us.
With the comparison policy on, 2 rows changed, 1 added, 1 removed remain:
what a reconciliation job actually wants to see.
Machine-readable output, the exit-code contract, and the duplicate-key guard:
Markdown summaries for PR comments, plus the test suite:
Upstream is deliberately small (212 lines of library code) and does one thing well. Running it against real reconciliation work — nightly exports, supplier feeds, API dumps vs database extracts — surfaces problems that are recorded in its own issue tracker and are not fixed:
| Upstream issue | Symptom when you hit it | What this fork does |
|---|---|---|
| #22 #23 #36 | --key accepts one column only, so any table with a composite primary key cannot be diffed |
--key id,region builds a composite key |
| #31 #54 | Two rows with the same key: the last one silently wins, the earlier row vanishes with no warning | duplicate keys abort with the offending line numbers; --on-duplicate first|last opts into merging |
| #52 #53 | Comparing two header-only (empty) CSVs raises StopIteration |
returns an empty result, exit 0 |
| #41 | Any single cell over 128 KB raises _csv.Error: field larger than field limit |
the limit is raised at import; 200 KB cells load fine |
| #19 | Files are opened as UTF-8 only; a GBK export from a Chinese office suite produces mojibake or UnicodeDecodeError |
encoding auto-detected (utf-8-sig → utf-8 → gb18030), --encoding to override |
| #20 | Cleo vs cleo is reported as a change |
--ignore-case |
| #21 | --format is the only delimiter control; an odd |-delimited export cannot be read |
--delimiter |
| #39 | Output is human text or a nested JSON blob; no flat, loadable format | --output csv|jsonl|json|markdown|table |
| — | Nothing to consume in CI: upstream always exits 0 | --fail-on any|changed|added|removed |
Two more gaps are not in the tracker but matter just as much for reconciliation, and are new here:
5vs5.00vs5are three different values upstream.--numeric-tolerance 0makes them equal;--ignore-whitespacehandles the padding.- A renamed column produces N changed rows plus a phantom column
add/remove.
--map-column old_name new_namecompares the two columns directly.
Scope note: upstream issue #40 (
Error when column name contains "..") is already fixed in upstream 1.2. It was verified during this work and is not claimed as a fix here.
pip install -e .Requires Python ≥ 3.8. Only runtime dependency is click; upstream's
dictdiffer dependency is no longer needed.
The console command is cdiff. The library is still importable as csv_diff,
so code written against upstream keeps working — see §6.
# Last night's export vs tonight's
cdiff data/tuesday.csv data/wednesday.csv --key id,region --output json -o report.json
# Fail a CI job if any value changed (new rows are fine)
cdiff baseline.csv current.csv --key id --fail-on changed
# Same thing via environment variable, e.g. in a GitHub Actions step
CDIFF_FAIL_ON=any cdiff a.csv b.csv --key id,regionExit codes — the whole contract:
| Code | Meaning |
|---|---|
0 |
No differences (or differences exist but --fail-on is none, the default) |
1 |
Differences found and --fail-on selected them |
2 |
Bad input: unreadable file, missing key column, duplicate keys, invalid JSON, --strict warnings |
--fail-on defaults to none so that existing upstream scripts keep their
behaviour. Opt in explicitly, or set CDIFF_FAIL_ON.
$ cat previous.csv
id,region,amount
1,EU,100
1,US,200
$ cdiff previous.csv current.csv --key id,region --output table
change key column previous current
------- ------ ------ -------- -------
changed 1 | US amount 200 250--key id alone would abort here, because 1 appears twice — which is the
correct outcome, and exactly what upstream cannot tell you.
$ cdiff before.csv after.csv --key id
change key column previous current
------- --- ------ ---------- ----------
changed 1 name Cleo cleo
changed 1 score 5 5.00
changed 2 note spaced spaced
$ cdiff before.csv after.csv --key id --ignore-case --ignore-whitespace --numeric-tolerance 0
change key column previous current
------ --- ------ -------- -------Same two files, four "differences" or zero — the second answer is the one a reconciliation job wants.
| Option | Effect |
|---|---|
--ignore-case |
Case-fold before comparing |
--ignore-whitespace |
Strip and collapse whitespace runs |
--numeric-tolerance N |
Equal if within N; 0 means numerically equal (5 == 5.00) |
--ignore-column a,b / --ignore-column '*' |
Leave columns out entirely |
--map-column OLD NEW |
Compare OLD (previous) against NEW (current) |
--empty-as-null |
"", null, none, na, n/a, nan all mean "missing" |
--output |
Shape | Use it for |
|---|---|---|
text (default) |
Upstream's exact human format | Reading in a terminal; unchanged from upstream |
table |
Fixed-width, CJK-aware alignment | Terminal review of wide tables |
csv |
One row per changed cell | Loading into a spreadsheet or SQL table |
jsonl |
One JSON object per line | Streaming into a log pipeline |
json |
{summary, column_stats, duplicates, added, removed, changed} |
Programmatic consumption |
markdown |
GitHub-flavoured tables | PR comment / $GITHUB_STEP_SUMMARY |
--json is kept separately and still emits upstream's exact 5-key structure.
# CSV export on the left, JSON API dump on the right
cdiff export.csv api.json --key id --output markdownFormat is chosen per file from its extension, so the two sides may differ.
Keys are matched as text, so a CSV 1 lines up with a JSON 1.
from csv_diff import load_table, reconcile, Normalizer
previous = load_table("tuesday.csv", key="id,region")
current = load_table("wednesday.csv", key="id,region")
normalizer = Normalizer(
ignore_case=True,
ignore_whitespace=True,
numeric_tolerance="0.01",
ignore_columns=["updated_at"],
column_map={"old_name": "new_name"},
)
result = reconcile(previous, current, normalizer=normalizer)
print(result.total_differences)
print(result.stat("amount").changes) # per-column change count
print(result.duplicates["previous"]) # duplicate keys, with line numbers
for entry in result.changed:
print(entry["display_key"], entry["changes"])Table is a dict subclass, so len(), indexing and equality all behave as
they did upstream. It carries extra metadata: columns, encoding,
key_columns, line_numbers, duplicates, ragged_rows, skipped_blank.
Measured on this machine (Windows, Python 3.13.14, warm cache, best of 5).
Reproduce with python bench/vs_upstream.py; the script asserts that both
implementations report the same number of changed rows before comparing
times, so the comparison is not apples-to-oranges.
Dataset: 6 columns (id, region, name, amount, qty, note), every 10th row has
one changed cell.
| Rows | File size / side | Fork load+reconcile | Throughput | Peak memory |
|---|---|---|---|---|
| 1,000 | 0.04 MB | 0.013 s | 76,000 rows/s | 1.6 MB |
| 10,000 | 0.45 MB | 0.130 s | 77,000 rows/s | 16.2 MB |
| 50,000 | 2.43 MB | 0.377 s | 127,000 rows/s | 85.1 MB |
| 200,000 | 10.22 MB | 1.532 s | 131,000 rows/s | 340.8 MB |
(median of 7 runs, minimum shown for throughput; measured when the machine's fixed CPU probe read 0.098 s.)
Fork vs upstream, single-column key, paired runs on the same input:
| Rows | upstream csv-diff 1.2 | this fork | overhead |
|---|---|---|---|
| 50,000 | 0.321 s | 0.357 s | +8% … +14% |
The fork is roughly 10% slower on the plain case. That is the honest cost of duplicate detection, encoding sniffing, empty-file handling and line-number tracking, which upstream does not do at all. The gap was measured at +89% in the first working version; two rounds of profiling brought it down (§8).
Cost of turning the policy on (50,000 rows): full normalisation (case + whitespace + tolerance) costs about +40% over strict. Rendering is negligible by comparison: JSON 0.010 s, JSONL 0.024 s, CSV 0.018 s, Markdown 0.001 s.
Measurement honesty: absolute times on this machine swung by up to 4× between
runs depending on background load (a fixed CPU probe ranged from 0.098 s to
0.62 s during this session). The figures above were taken at 0.098 s probe
time. Under load the same workload took 1.07 s instead of 0.377 s. Re-run
python bench/stable.py and python bench/vs_upstream.py on your own hardware;
the scripts print the probe reading alongside the timings so you can judge
whether a number is trustworthy.
The upstream test suite (24 tests) runs unmodified and passes. Compatibility was treated as a hard constraint, not an aspiration:
load_csv(fp, key=None, dialect=None),load_json(fp, key=None),compare(previous, current, show_unchanged=False)andhuman_text(result, key, singular, plural, current, extras)keep their signatures and return shapes.--jsonstill emits the exact 5-key dictionary.- Default text output is character-for-character upstream's.
- Exit code 0 on differences is preserved;
--fail-onis opt-in. - Warnings go to stderr and are off by default (
--warnings), so nothing new appears in stdout. - JSON integer ids stay
intkeys, matching upstream's behaviour.
Where a new behaviour would have contradicted upstream, it is opt-in via a flag rather than a changed default.
Every file in this repository is accounted for:
| File | Origin |
|---|---|
csv_diff/loader.py |
New. No upstream equivalent |
csv_diff/normalise.py |
New. No upstream equivalent |
csv_diff/compare.py |
New, with compare() reimplemented to be drop-in compatible with upstream's |
csv_diff/report.py |
human_text() / human_row() / human_extras() copied verbatim from upstream csv_diff/__init__.py; all other renderers new |
csv_diff/cli.py |
Rewritten. Upstream's 10 options kept with identical semantics; 16 new options added |
csv_diff/__init__.py |
Rewritten as a facade. Upstream's four public functions are re-exported with compatible behaviour |
tests/test_cli.py, tests/test_csv_diff.py, tests/test_human_text.py |
Copied verbatim from upstream, unmodified, as the compatibility guard |
tests/test_reconcile.py, tests/test_cli_reconcile.py |
New. 79 tests for the added behaviour |
bench/*.py |
New |
LICENSE |
Upstream's Apache-2.0 text, unmodified |
NOTICE |
New. Attribution and list of modified files, per Apache-2.0 §4(b)/(c) |
Line counts:
| lines | |
|---|---|
Upstream library code carried over (csv_diff/__init__.py original, 212 + cli.py 82) |
294 |
| Upstream test code carried over (byte-identical) | 643 |
New library code (loader 467 + normalise 205 + compare 419 + report 376 + cli 470 + __init__ 71) |
2,008 |
| New test code (79 tests) | 795 |
| Benchmarks | 296 |
The 2,008 figure includes the three upstream report functions copied into
report.py (~90 lines), so genuinely new library code is roughly 1,900 lines.
The test count and line counts above are from wc -l and pytest, not estimates.
Why duplicate keys are an error, not a warning. Silently dropping a row is the worst possible behaviour for reconciliation: the diff reports "no change" for a row that is simply missing. Line numbers are included because the fix is almost always to extend the key.
Why line numbers are tracked. --output csv has a line column so a
flagged difference can be opened directly at the offending row of the source
file. Upstream discards this information during loading.
Why the composite key uses \x1f. Unit Separator cannot appear in CSV or
JSON text, so there is no ambiguity with a column value that happens to contain
a comma. It is expanded to | only for display.
Why Table subclasses dict. Upstream's tests assert
load_csv(...) == {...}. Subclassing keeps that true while allowing metadata
to ride along, which avoided forking every call site.
Why the policy is opt-in. Defaulting --ignore-case on would make the tool
quietly miss genuine case-only changes in identifiers. The default stays strict;
the flags document the intent at the call site.
Performance work (measured, not guessed). Profiling with cProfile showed
reconcile at 1.02 s and _row_changes at 0.88 s of a 1.56 s run. Fixes, in
order:
- A C-level dict equality pre-filter per row (upstream does the same), so unchanged rows never enter per-cell comparison.
Normalizer.is_identity— when the policy is the default strict one, skip every per-cell policy call.- Cache
canonical_columnlookups (605k calls were being made). - Hoist loop invariants in the loader and drop a redundant blank-line scan that was costing an extra pass over every row.
Result: 50,000 rows went from 0.718 s to 0.377 s, and the gap to upstream from +89% to about +10%.
These are real and not worked around:
- Both files are held in memory. Peak RSS at 200,000 rows is ~341 MB. A streaming implementation would need a sort-merge join; that is a different architecture, not a tweak. Large-file work is out of scope here.
- Encoding detection is a guess by try-decode. A GBK file that happens to
be valid UTF-8 will be read as UTF-8.
--encodingis the escape hatch, and--warningsreports which encoding was chosen. - Numeric tolerance uses
Decimal. Very large exponents or values with more than ~28 significant digits may round. Values that are not numeric are never coerced, so1,234stays text. --map-columnis one-to-one. Merging two old columns into one new column is not supported.--ignore-casealso folds the key. That is usually what you want, but two ids differing only in case will be merged, and--on-duplicatedecides which survives.- Markdown/table reports truncate at
--max-rows(default 50) for review; the JSON/CSV/JSONL reports are never truncated. - No streaming stdin for JSON.
-is read as CSV only. - Column-order changes are not reported as a difference. Upstream does not either; only added/removed columns are reported.
- "Changes by column" counts raw differences, not business impact. In the
example data,
updated_atshows up as the most changed column (4 cells) simply because every touched row has a new timestamp. The report tells you where the differences are, not which ones matter — use--ignore-columnto remove the noise before reading the ranking. --ignore-caseshows the folded key in the table report. With--ignore-casethe displayed key is the case-folded form (a-1001 | eurather thanA-1001 | EU), because that is what matching happened on. The JSON/CSV reports keep the original row values.
pytest -q # 103 tests
python bench/benchmark.py --all
python bench/stable.py # median-of-N, prints a CPU probe reading
python bench/vs_upstream.py # needs ../research2/csv-diff-main vendored
python tools/make_banner.py # regenerates docs/screenshots/hero.png
python tools/make_shots.py # regenerates the three terminal screenshots.github/workflows/test.yml runs on every push:
- test — 9 combinations (Ubuntu / Windows / macOS × Python 3.9 / 3.11 / 3.13),
running
pytest, a CLI smoke test, and an assertion that the exit-code contract still holds (--fail-on anymust return 1 with differences and 0 without). - upstream-compat — pins the SHA-256 of the four upstream test files. If a future commit edits one of them, the compatibility claim in §6 stops being true and this job fails. That is the point: the claim is enforced, not asserted in prose.
The screenshot tools call the real cdiff binary and read their numbers back
from --output json, so a caption cannot drift away from what the tool
actually reports.
Apache-2.0. This is a derivative work of csv-diff by Simon Willison; see
LICENSE and NOTICE. Upstream's copyright notice is
retained, and NOTICE lists every file that was modified.



