JSON Diff Tools: jq Deep Equality, jsondiffpatch, VSCode, and When the Browser Is Enough
There are two questions people mean when they say "diff this JSON", and tools answer different ones. Are these two documents equal? is a semantic question — key order irrelevant, 1 versus 1.0 relevant, {"a":1,"b":2} equals {"b":2,"a":1}. What changed? is a structural question — which paths differ, what were the old and new values. A textual diff answers neither reliably: reformatting a file with jq . produces a wall of false positives. Everything below was run on jq 1.7 and Node 24 on this machine; where a claim is version-gated, the gate is printed.
jq 1.7 does not have --argfile
The recipe copied around the internet is:
jq -n --argfile a a.json --argfile b b.json '$a == $b' # jq 1.8+ only
On jq 1.7 — the version shipping on current Ubuntu/Debian — that is a hard failure: jq: Unknown option --argfile. Not a warning; exit 2, nothing compared. jq 1.8 introduced --argfile; until your distro catches up, use the flags that are in 1.7: --rawfile (file contents as a string, then fromjson) or --slurpfile (contents as an array of parsed values, so index [0]). The --rawfile form is the cleaner one for diffs:
jq -n --rawfile x a.json --rawfile y b.json '
($x | fromjson) as $A | ($y | fromjson) as $B | $A == $B'
Verified on {"a":1,"b":[1,2],"c":"x"} vs {"a":1,"b":[1,3],"d":true}: prints false. The == operator is jq's deep equality — recursive, order-sensitive for arrays (correctly: arrays are ordered), order-insensitive for objects (also correct), numeric-typed (1 == 1.0 is true in jq; 1 == "1" is false). For a CI gate, wrap it: jq -e -n ... >/dev/null and let the exit code decide.
--args does not load files — it consumes trailing CLI arguments as strings (jq -n --args '$ARGS.positional' a b → ["a","b"]). For positional JSON, jq 1.7 has --jsonargs. Mixing them up makes a jq diff compare the literal text of a filename.
A real path-level diff in jq 1.7
== answers "same?"; this answers "where?". Collect every path in either document, report the paths whose values differ:
jq -n --rawfile x a.json --rawfile y b.json '
($x | fromjson) as $A | ($y | fromjson) as $B
| [ ($A | [paths])[], ($B | [paths])[] ] | unique
| map(. as $p | select(($A | getpath($p)) != ($B | getpath($p)))
| {path: ($p | map(tostring) | join(".")), a: ($A | getpath($p)), b: ($B | getpath($p))})'
On the two files above that returns four entries — b (array differs wholesale), b.1 (2 vs 3), c (value vs null = missing), d (null vs value = added). Verbose on purpose: both sides visible beats a color smear. One trap: run this filter without fromjson — paths($x) on a --rawfile string returns [] with a straight face. No error, no warning, "no differences". Always bind through fromjson first.
Two smaller shapes worth having memorized:
# key-level only (top level): added/removed keys
jq -n --rawfile x a.json --rawfile y b.json '
($x|fromjson) as $A | ($y|fromjson) as $B
| {onlyA: ([$A|keys_unsorted[]] - [$B|keys_unsorted[]]),
onlyB: ([$B|keys_unsorted[]] - [$A|keys_unsorted[]])}'
# array as a set, order ignored (config lists, permission arrays)
jq -n --rawfile x a.json --rawfile y b.json '
($x|fromjson) as $A | ($y|fromjson) as $B
| {onlyA: ([$A[]] - [$B[]]), onlyB: ([$B[]] - [$A[]])}'
The set form is the right diff for ["read","write"] vs ["write","read"] and the wrong one for anything where order is data — the same command, opposite verdicts, depending on what the array means. No tool knows which you have.
Textual diff, done right
Semantic diffs are rarer than they should be, so the pragmatic move is: canonicalize, then run any diff.
diff <(jq -S . a.json) <(jq -S . b.json)
-S sorts keys, which makes the textual diff agree with structure for objects. It still cannot tell a moved array element from a changed one, and it still splits large string values across lines. git diff --word-diff, delta, diff --side-by-side all sit behind this same canonicalize-first trick — format is a preprocessor, not a diff tool. VSCode's built-in comparer (Compare Selected, or the Source Control view) is the same mechanism with syntax highlighting and scroll-sync, and its JSON-aware fold handling means it stays readable on a 4,000-line config. For two files you are eyeballing once, it is the fastest option on this list. For a pipeline artifact (config drift, API contract regression), use the jq filters — you can put them in CI.
Structural diff as a library
When the diff is data — stored, replayed, applied to another document — use JSON Patch (RFC 6902) as the wire format. The jsondiffpatch npm package is the usual one: diff() produces a patch array of {op: "add"|"remove"|"replace"|"move", path, value} objects, and the patch is both the report and the fix. json0 variant adds add/remove/replace on array indices without RFC 6902's escaping rules. The practical value is idempotent migration: store the patch, apply it to a fresh copy of the old doc, verify you get the new one. fast-json-stable-stringify or json-stable-stringify sit underneath for the key-order problem.
Library diffs are not free: they compare arrays element-by-element by default, so a reordered array of 10,000 records reads as 10,000 replaces unless you configure an identity key. jq has no built-in diff/patch in 1.7 — this is why the getpath filter above exists in this guide.
When the browser tab is enough
Being honest about the tool you are reading this on: the beautifier is the right diff for the 80% case — two payloads under roughly a hundred kilobytes, one human looking, no history to keep. Beautify pane A, beautify pane B, compare visually; the formatter already did the canonicalization that makes the eyeball-diff trustworthy, and nothing leaves your machine. It is not the right tool when: the files don't fit in memory (see large JSON files when it ships, and the gotchas on precision loss in the meantime — a differ that parses through JSON.parse will happily show you …12345600 where the source said …12345678), when you need the diff as an artifact, or when "equal" must mean byte-identical-after-normalization rather than looks the same. Those are the cases jq -e in CI is built for, and the reason this page exists instead of just "use the button".
FAQ
Why does my jq diff script work on my Mac and fail in CI? Almost certainly the version: Homebrew ships jq 1.8 (--argfile exists), apt ships 1.7 (it does not — Unknown option, exit 2). The --rawfile+fromjson form at the top of this page runs on both.
Does jq == ignore key order? Yes, for objects. No, for arrays — and that asymmetry is correct, because that's what the two JSON types mean.
paths returned nothing and the diff says "identical" — is my file empty? Check you parsed. paths($raw) on a --rawfile string yields [] without complaining. Bind ($raw|fromjson) first, every time.
Two API responses diff everywhere; is the API broken? Usually key order plus dynamic fields (timestamps, request IDs). Canonicalize with jq -S, then filter the volatile keys (del(.receivedAt, .requestId)) before concluding anything.
Best tool for a 2 GB diff? Neither side of this page. Stream both as NDJSON, key them, compare per-record — see the NDJSON guide for the format.