Why a text diff fails on JSON
A text diff compares lines. JSON gives you at least three ways to change every line without changing a single value: press Format, and every line changes; pass the file through a language whose maps iterate differently, and every key sequence changes; parse and re-print the document, and 1.0 becomes 1.
The result is the worst kind of report: hundreds of changes, none of them real. The opposite failure is worse still — a change that is real and invisible, which the last section of this guide covers.
A structural comparison reads both documents into values first, then compares values. Formatting is gone by then, because it was never part of the value.
Three kinds of order, only one of them obvious
Every JSON comparison has to answer the same question before it can report anything: which orders count as differences?
| Order | What it is | When it matters |
|---|---|---|
| Line order | The order of lines and indent characters in the file. | In a text editor only. Never in the data. |
| Key order | The sequence of members inside one object. | For signatures, ETags and any cache keyed on the exact bytes. Not for the data. |
| Array order | The sequence of elements inside one array. | Depends on the field — a list of tags and a list of steps differ. |
Line order is never a data question, so no useful comparison considers it. Key order is almost never a data question — but it is a byte question, and signatures live on bytes. Array order is the one that has no default answer, because only you know whether ["a", "b"] and ["b", "a"] mean the same thing.
What a useful report looks like
The useful unit is not a rendered tree of both documents side by side — that is a reading tool. The useful unit is one row per change, with a path and a type, so the result can be counted, pasted into a ticket or asserted on in a test.
- added — A member or element exists only in the second document. Carries the new value.
- removed — A member or element exists only in the first document. Carries the old value.
- changed — The same path holds a different primitive, or a different kind. Carries both values and a short description such as number → string.
- reordered — The same members, in a different sequence. Only reported when key order is treated as significant.
Here is real output for one order payload that changed while it was being shipped. Both documents are the ones on the JSON Diff page, in the default mode:
| Type | Path | Value |
|---|---|---|
| changed | $.status | "pending" → "shipped" |
| removed | $.customer.vip | false (gone) |
| changed | $.items[1].quantity | 2 → 3 |
| added | $.items[2] | {"sku":"MS-07","quantity":1} |
| added | $.shippedAt | "2026-10-23T07:40:00Z" |
Five rows, five changes, and the shape of the change is visible without opening either document: a status moved, a flag was dropped, a quantity was edited, an item and a timestamp were added.
Reading the paths
Paths use JSONPath's notation because it is already familiar from jq and from API tooling. Four forms cover everything:
$ the document root
$.status a member whose name is an identifier
$.items[1].quantity the second element of an array, then a member
$["odd key"] a member whose name is not an identifierThe bracket form matters more than it looks. Keys with spaces, dots or emoji are legal JSON, and $.odd key would be ambiguous — so any key that is not a plain identifier is quoted, exactly as a JavaScript accessor would be.
Array order is a decision you have to make
Compare the same two documents again, this time matching array elements by value instead of by position. The total goes from five rows to six, and the interesting part is which row changed shape:
| Type | Path | Value |
|---|---|---|
| changed | $.status | "pending" → "shipped" |
| removed | $.customer.vip | false (gone) |
| removed | $.items[1] | {"sku":"CBL-03","quantity":2} |
| added | $.items[1] | {"sku":"CBL-03","quantity":3} |
| added | $.items[2] | {"sku":"MS-07","quantity":1} |
| added | $.shippedAt | "2026-10-23T07:40:00Z" |
The quantity edit at $.items[1] turned into one removal and one addition. That is not a bug — it is the honest consequence of the mode. If elements are matched by value, a pair that matched is identical by construction, so no matched pair can ever be reported as changed. An edited element is, by definition, an element that no longer has a match.
Use positional comparison when the array is a sequence (steps, messages, the lines of a stack trace). Use value matching when it is a set (tags, permissions, the members of a team). Applying the wrong one produces the two classic complaints: “it says everything moved” and “it says nothing changed”.
The difference parsing cannot see
A comparison of parsed values inherits every property of the parse — including the one nobody wants. These two documents differ by one:
{"id": 9007199254740993}
{"id": 9007199254740992}Parsed and compared, the result is zero changes. Both numbers are read as the same IEEE-754 double, because 2^53 is the largest integer a double can hold exactly, and 9007199254740993 is one past it. Printed back, both become 9007199254740992.
So on any document with 64-bit IDs — snowflake IDs, Twitter-style IDs, database bigints serialised as numbers — a structural diff can report “no differences” on two files that a text diff correctly separates. Neither tool is lying; they are looking at different representations, and the parsed one has already lost the bit.
The fix is not in the diff. Serialise identifiers as strings, which is what most APIs do for exactly this reason, and keep them as strings through every layer that might parse them. The precision guide has the full round-trip table.
Where this fits in a workflow
- Contract changes. Compare a recorded response before and after a deploy. Twelve rows is a reviewable change; four hundred is a rewrite, and the count alone tells you which.
- Golden files in tests. Store the fixture sorted and indented so a text diff stays readable, then use a structural comparison for the assertion — otherwise a harmless reorder fails the suite.
- Staging against production. With array order ignored, the remaining rows are usually environment differences rather than code differences: timestamps, hostnames, feature flags.
- Signature debugging. Turn key order back on. If the only rows are
reordered, the data never changed and the signature did — which is a canonicalisation bug, not a payload bug.
The comparison itself runs in the browser tab, which matters for the one case where you cannot upload the file: comparing two production payloads that contain real customer data.