Skip to content
DevTinyTools

Diffing

How to compare two JSON files without drowning in key order

Reformatting a file rewrites every line, a map serialised by another language reorders every key, and parsing turns 16-digit IDs into doubles. A useful comparison has to decide what counts as a difference before it looks at one.

9 min readTool: JSON Diff

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?

Three kinds of order in a JSON document
OrderWhat it isWhen it matters
Line orderThe order of lines and indent characters in the file.In a text editor only. Never in the data.
Key orderThe sequence of members inside one object.For signatures, ETags and any cache keyed on the exact bytes. Not for the data.
Array orderThe 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:

Five changes reported between two order payloads
TypePathValue
changed$.status"pending" → "shipped"
removed$.customer.vipfalse (gone)
changed$.items[1].quantity2 → 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 identifier

The 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:

The same comparison with array order ignored
TypePathValue
changed$.status"pending" → "shipped"
removed$.customer.vipfalse (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.

Frequently asked questions

Can I just run diff on the two files?
You can, and it is fine when both files were written by the same tool with the same settings. It stops being fine as soon as one side is reformatted or produced by a different language: the diff then reports every line as changed, which is true about the file and wrong about the data.
Why does my diff show a change when the values look identical?
Usually a type difference rather than a value difference: 1 against "1", or true against "true". A structural diff reports those as changed and prints the kinds, because a string where a number belongs will break a consumer even though it reads the same.
Does the comparison follow RFC 6902 JSON Patch?
No. JSON Patch is an instruction format for applying changes: paths plus operations such as add, remove and replace. This is a report of what differs, which is a different job. The path syntax is deliberately close so the output can be read without learning anything new.
What happens when documents differ in thousands of places?
The list stops at 500 entries and says so. An unbounded list is unusable in a browser and hides the shape of the change; the first 500 rows normally show whether the difference is one field or a whole new schema.
Is order-insensitive array matching expensive?
It is linear per array: each element is reduced to a canonical string with sorted keys, and matched against a pool. That is fine for documents of normal size. The cost only matters for arrays with tens of thousands of objects, where the strings themselves become the memory problem.