🍱 Lunchbox Hands

json

JSON Diff Is Not Text Diff: Key Order, 1 vs 1.0, and What 'Changed' Means

A line diff of two JSON documents reports changes that are not changes (key order, whitespace, 1 vs 1.0) and hides changes that are (1 vs "1", null vs missing). What RFC 8259 actually says, how a structural diff decides, where ours uses index matching instead of LCS, and JSON Patch as the machine-readable answer.

Two API responses, one from staging and one from production, and diff says they differ on 40 lines. You read all 40 and every one of them is the same key/value pair sitting one line higher or lower than before. Meanwhile the actual regression — an ID that came back as the string "1" instead of the number 1 — is one character inside a line that reads as changed for the wrong reason, and you scroll straight past it.

A text diff compares bytes in sequence. A JSON diff compares values in a tree. They answer different questions, and the interesting cases are exactly the ones where the answers disagree. This post walks through those cases with real output from diff, git diff, and our JSON Diff tool — every code block below is actual, executed output from the tool’s diffing library — and finishes with the two RFC formats for writing a diff down so a machine can apply it.

Case 1: key order is not a change

RFC 8259, the JSON spec, opens with the definition: “An object is an unordered collection of zero or more name/value pairs.” Section 4 adds that “JSON parsing libraries have been observed to differ as to whether or not they make the ordering of object members visible to calling software” and that “implementations whose behavior does not depend on member ordering will be interoperable.” (RFC 8259 §1, §4)

So {"a":1,"b":2} and {"b":2,"a":1} are the same object. A line diff has no idea:

$ diff a.json b.json
1c1
< {"a":1,"b":2}
---
> {"b":2,"a":1}

Pretty-print both and it gets worse, not better — now it is two removed lines and two added lines, which is what our Text Diff tool (line-based, built on the diff package) reports for the same pair:

+2 added  -2 removed
 {
-  "a": 1,
-  "b": 2
+  "b": 2,
+  "a": 1
 }

A structural diff parses both sides and compares by key, so order never enters into it. Our tool’s equality check serializes each subtree with its keys sorted before comparing, which is why the reordered pair comes back as nothing:

left : {"a":1,"b":2}
right: {"b":2,"a":1}
No differences.

Tick “Show unchanged” and you get a single = (root) line: the whole tree matched at the top, so it never descended.

The CLI workaround is to canonicalize before diffing. jq -S sorts keys recursively — sorting is a transformation, which is the line between jq and a JSONPath selector — and process substitution feeds both sorted forms to diff:

$ diff <(jq -S . a.json) <(jq -S . b.json)
$ echo $?
0

Without jq, JSON.stringify takes a replacer that can rebuild every object with sorted keys:

const norm = (s) => JSON.stringify(JSON.parse(s), (k, v) =>
  v && typeof v === 'object' && !Array.isArray(v)
    ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]))
    : v, 2);
norm('{"a":{"c":1,"d":1},"b":2}') === norm('{"b":2,"a":{"d":1.0,"c":1e0}}'); // true

Notice what that last line snuck in: 1.0 and 1e0 on the right compared equal to 1 on the left. That is case 2.

Case 2: 1, 1.0, and 1e0 are the same number (until they aren’t)

JSON’s number grammar allows a fraction and an exponent, but it does not define a type distinction between “integer” and “float” — there is one number production. RFC 8259 §6 says interoperability is good for implementations that “expect no more precision or range than” IEEE 754 binary64 doubles, and that integers in “the range [-(253)+1, (253)-1] are interoperable in the sense that implementations will agree exactly on their numeric values.” (RFC 8259 §6)

In JavaScript that means one thing: parse and the spelling is gone.

$ node -e 'console.log(JSON.parse("1") === JSON.parse("1.0"), JSON.parse("1") === JSON.parse("1e0"))'
true true
$ node -e 'console.log(JSON.stringify(JSON.parse("1.0")), JSON.stringify(JSON.parse("1.50")))'
1 1.5

Our tool inherits this directly — it runs JSON.parse on both panes and compares the resulting values, so 1 vs 1.0 vs 1e0 (and 0 vs -0, which JSON.stringify also collapses) all report No differences.:

left : {"n":1}
right: {"n":1.0}
No differences.

For most data that is the right answer: a producer that switched from 36 to 36.0 did not change anyone’s age. But there are two honest caveats.

Caveat one: some tools preserve the literal. jq 1.7 and later keep the original spelling of any number they don’t do arithmetic on — its release notes say it uses “decimal number literals to preserve precision. Comparison operations respects precision but arithmetic operations might truncate.” (jq 1.7 release notes) So the jq -S trick from case 1 does not normalize number spelling — with jq 1.8.2 here:

$ diff <(echo '{"n":1}' | jq -S .) <(echo '{"n":1.0}' | jq -S .)
2c2
<   "n": 1
---
>   "n": 1.0

Caveat two: the precision cliff. Above 2⁵³, doubles can’t represent every integer, and JSON.parse silently rounds:

$ node -e 'console.log(JSON.parse("9007199254740993"))'
9007199254740992

That is a real change that our tool cannot see. Paste {"id":9007199254740993} on the left and {"id":9007199254740992} on the right and it reports No differences. — because by the time the diff runs, both sides are the same double. Twitter-style snowflake IDs, 64-bit database keys, and financial amounts stored as integer cents all live up there. If your documents carry integers past 9007199254740991 (Number.MAX_SAFE_INTEGER), a JavaScript-based diff will lie to you about them, and this includes ours. RFC 8259 anticipated the problem: a number “such as 1E400 or 3.141592653589793238462643383279 may indicate potential interoperability problems, since it suggests that the software that created it expects receiving software to have greater capabilities for numeric magnitude and precision than is widely available.” The fix upstream is to emit big IDs as strings, which most APIs that survived contact with JavaScript clients already do.

Case 3: whitespace and formatting are nothing

This one is obvious once cases 1 and 2 are on the table, but it is the most common false positive in practice. A compact document and its pretty-printed twin differ on every line to a text tool:

$ diff a.json pretty.json
1c1,4
< {"a":1,"b":2}
---
> {
>   "a": 1,
>   "b": 2
> }

and are identical to a structural one. If all you need is to make two documents text-comparable, run both through the JSON Formatter at the same indent first; the formatter preserves key order (JSON.stringify keeps insertion order), so this fixes whitespace but not case 1.

Case 4: array order is a change — and index matching overreports it

Objects are unordered; arrays are not. [1,2,3] and [3,2,1] are different values, and any diff should say so. The question is how it says so.

Our tool matches array elements by index: position 0 against position 0, and so on. That is simple and fast, and for arrays where elements were edited in place it gives exactly the right answer. It gives a misleading one when an element is inserted or removed anywhere but the end:

left : {"tags":["b","c","d"]}
right: {"tags":["a","b","c","d"]}
~ tags[0] : "b" → "a"
~ tags[1] : "c" → "b"
~ tags[2] : "d" → "c"
+ tags[3] : "d"

One insertion at the front, reported as three changes and one addition — every element after the insertion point shifted, and the index-based comparison faithfully describes the shift. A line diff, ironically, does better here because it is a sequence algorithm (Myers, or the LCS-style algorithms in git diff --diff-algorithm=patience|histogram):

+1 added  -0 removed
 [
+  "a",
   "b",
   "c",
   "d"
 ]

So our tool does not do LCS or move detection on arrays. That is a trade-off — LCS over arrays of objects needs an identity rule (match on id? on deep equality?) that no tool can guess — but know it going in: a cascade of ~ items[n] lines that each look like the previous element is almost certainly one insertion, not N edits. Deleting from the end is reported cleanly (- [2] : 3 for [1,2,3] → [1,2]).

Case 5: 1 vs "1" — the change text diff can’t name

A type change is visible in a text diff only as two quote characters buried in a line that is otherwise identical:

 {
-  "id": 1
+  "id": "1"
 }

A structural diff carries the type through to the report. Our tool prints values with JSON.stringify, so strings keep their quotes and the change reads as what it is:

~ id : 1 → "1"
~ active : true → "true"

That second line is the classic form-serialization bug — a boolean that became the string "true" on its way through a query string (JSON to Query String shows exactly where that flattening happens) — and it is precisely the kind of change that vanishes in a 40-line text diff. The comparison is strict: stringify(1) is 1 and stringify("1") is "1", different strings, no coercion anywhere in the pipeline.

Case 6: null is not “missing”

{"email":null} and {} are different documents. The first has an email member whose value is null; the second has no email member. Some consumers treat these the same (a form that clears a field); many do not (a PATCH endpoint, a schema with required: ["email"], a database column with a NOT NULL default). A diff has to distinguish them, and ours does, because it checks key presence with in before it ever compares values:

left : {"email":null}
right: {}
- email : null

left : {}
right: {"email":null}
+ email : null

left : {"email":null}
right: {"email":"[email protected]"}
~ email : null → "[email protected]"

Removed, added, changed — three different verbs for three different edits. This distinction turns out to be the crux of the second RFC format below.

Two smaller behaviors worth knowing, read from the same source: duplicate keys are resolved by JSON.parse before the diff runs (last wins, which RFC 8259 §4 notes “many implementations” do), so {"a":1,"a":2} vs {"a":2} is No differences.; and paths are printed with dots, so a key that literally contains a dot ("a.b") prints as a.b — the same ambiguity our Flattening JSON for CSV post covers, and the JSON Flattener documents its own handling of.

Writing the diff down: JSON Patch and JSON Merge Patch

Everything above produces a diff for a human. Two IETF formats produce one for a machine.

JSON Patch (RFC 6902) is “a JSON document structure for expressing a sequence of operations to apply to a JavaScript Object Notation (JSON) document”, media type application/json-patch+json. Six operations — add, remove, replace, move, copy, test — each targeting a JSON Pointer path. Operations “are applied sequentially in the order they appear in the array”, and if any one fails, “application of the entire patch document SHALL NOT be deemed successful” (§3, §5). Take the tool’s own default example, one step further:

left : {"name":"Ada","age":36,"tags":["math"]}
right: {"name":"Ada","age":37,"tags":["math","engines"],"active":true}
~ age : 36 → 37
+ tags[1] : "engines"
+ active : true

As a JSON Patch, that is:

[
  { "op": "replace", "path": "/age",    "value": 37 },
  { "op": "add",     "path": "/tags/-", "value": "engines" },
  { "op": "add",     "path": "/active", "value": true }
]

The - index is the spec’s “append” — RFC 6902 §4.1 says it “has the effect of appending the value to the array.” The tool’s verbs map directly: ~ is replace, + is add, - is remove, and the case-6 distinction survives — removing email is {"op":"remove","path":"/email"}, while setting it to null is {"op":"replace","path":"/email","value":null}. (Applying that three-op patch to the left document reproduces the right one exactly; verified with a minimal applier before pasting.)

Our tool does not emit JSON Patch. Its output is the path/kind/left/right list shown throughout this post — the translation above is mechanical, but if you need application/json-patch+json on the wire you want a library that generates it, ideally one that also emits move ops, which ours (see case 4) cannot detect.

JSON Merge Patch (RFC 7396) is the simpler cousin: the patch is a JSON document shaped like the target, and you merge it in. The same change is just the right-hand side minus the unchanged name:

{ "age": 37, "tags": ["math", "engines"], "active": true }

Two gotchas come with the simplicity. First, per the RFC, “null values in the merge patch are given special meaning to indicate the removal of existing values in the target” — so {"email": null} as a merge patch deletes email, and there is no way to set a value to null with merge patch at all. Second, “it is not possible to patch part of a target that is not an object, such as to replace just some of the values in an array” — arrays are replaced wholesale, which is why the patch above has to restate ["math","engines"] rather than append. If your API accepts application/merge-patch+json, case 6 is the one that will bite: a client that serializes an unset optional field as null will delete server-side data it never meant to touch.

What about git diff?

git diff is a text diff with word-level options bolted on. --word-diff “operates by taking the same line-by-line diff that is produced without the option and computing word-by-word changes within each hunk”, and by default a word is a run of non-whitespace (git-diff docs). Minified JSON has no whitespace, so the default word-diff of a one-key change is the whole line:

$ git diff --word-diff
[-{"a":1,"b":2,"c":3}-]{+{"a":1,"b":20,"c":3}+}
$ git diff --word-diff --word-diff-regex='[^,{}]+'
{"a":1,[-"b":2-]{+"b":20+},"c":3}

The regex form gets you to the pair, but it is still string matching — reordered keys, 1.0, and whitespace are flagged exactly as diff flags them. For JSON committed to a repo, the reliable pattern is to canonicalize on the way in (sorted keys, fixed indent — a pre-commit hook or a .gitattributes clean filter running jq -S) so that every text diff is also a semantic diff. Day-to-day commands are in the Git cheat sheet.

Which tool for which question

You want to knowUseBecause
Did the values change, ignoring order and formatting?JSON DiffParses both sides; sorted-key equality; null vs missing distinguished; types shown
Which lines changed in a config file I hand-edit?Text DiffLine-based sequence diff; handles insertions in arrays cleanly
Two documents differ only in whitespace?JSON Formatter both, then text-diffNormalizes indent (not key order)
Compare nested docs as flat key/value rows?JSON FlattenerPath-per-row makes sorting and spreadsheet comparison possible
The diff must survive as a wire formatJSON Patch / Merge PatchRFC 6902 / RFC 7396 — our tool’s output maps onto Patch by hand, but doesn’t emit it

If one side won’t even parse, there’s nothing to diff yet: fix the syntax first, and JSON “Unexpected token” errors explained decodes what each browser’s error message is pointing at.

And the summary of what our diff actually does, straight from the source: sorts keys for equality (order is never a change), treats 1 and 1.0 as equal (and can’t see integers past 2⁵³), ignores whitespace, matches arrays by index (no LCS, no move detection), distinguishes 1 from "1" and null from missing, and does not emit JSON Patch. The related format tour continues in NDJSON is not a JSON array — where the question is not “what changed” but “where does one document end.”