2026-09-10
You export a CSV, someone edits it in a spreadsheet, and now you're staring at a diff that thinks every row moved because they saved with different quoting rules. Or the primary keys got sorted differently. Or a column was inserted at position 3 and every subsequent field looks "changed."
Text diff is the wrong tool for tabular data. daff — by Paul Fitzpatrick, in Debian as daff, on npm as daff, in Homebrew as daff — is the right one. It treats rows, columns, and primary keys as first-class citizens.
$ daff old.csv new.csv
@@,name,role,salary
+++,Rex,SRE,180000
->,Alex,Engineer -> Senior,120000 -> 145000
---,Sam,Manager,200000
Each leading marker tells you what happened: +++ row added, --- row removed, -> cell edited (with old→new inline). Column reordering is silently normalized. Row reordering is handled too — when a primary key can be inferred, daff finds the "same" row across files by content, not position.
daff merge parent.csv theirs.csv ours.csv produces a merged file with conflict markers inside cells, not scrambled across lines.daff diff a.csv b.csv --output changes.csv then daff patch a.csv changes.csv. Ship the diff itself as an artifact, review it, apply it later.daff diff a.csv b.csv --output review.html gives you a color-coded table where reviewers actually see what changed cell by cell.The real superpower is teaching git to use daff for .csv diffs. In .gitattributes:
*.csv diff=daff-csv
*.csv merge=daff-csv
In .git/config:
[diff "daff-csv"]
command = daff diff --git
[merge "daff-csv"]
name = daff tabular merge
driver = daff merge --inplace %A %O %B %A
Now git diff and git merge on CSVs Just Work. The daff project ships daff git csv which configures both for you in one command.
daff reads TSV, SQLite tables, JSON arrays-of-objects, and Excel via daff-xlsx. Same diff semantics everywhere. Compare a SQLite dump against yesterday's:
$ daff yesterday.sqlite today.sqlite --table users
You get a proper row-level report instead of a binary "files differ."
By default daff guesses the primary key. When it guesses wrong, tell it:
$ daff --id user_id old.csv new.csv
This is the flag that transforms daff from "neat" to "essential" — once it knows the identity column, it stops caring about row order and starts tracking each row's lineage across the two files. Rows that were reordered, moved by a sort, or interleaved by an INSERT stop appearing as false churn.
Written in Haxe, compiled to Node, Python, PHP, and native binaries — so it runs anywhere your build server does.
