JSON Diff, Explained

Read this to understand how a structural JSON diff finds every added, removed and changed value, why it ignores key order and formatting, and how to read a path like user.roles[2] back to the exact place a value changed.

What a structural diff actually compares

A line-based diff, the kind git diff shows, treats a file as a sequence of text lines. Reindent one object and every line looks changed, even though the data is identical. A structural JSON diff parses both documents into trees of objects, arrays, strings, numbers, booleans and nulls, then walks the two trees in parallel. It reports a difference only when the parsed values differ.

Here is the hook. Compare these two documents:

{"a":1,"b":2} against { "b": 2, "a": 1 }.

A line diff flags both lines as changed. A structural diff reports zero differences, because JSON objects are unordered maps and both documents describe the same map. The tool at /dev/json-diff/ works this way: it parses first, compares second.

When to use it, and when not to

Reach for a structural diff when the thing you care about is the data, not the text. Good cases: comparing an API response before and after a deploy, checking that a config change touched only the two keys you meant to touch, or verifying that a reformatting pass left values untouched.

Skip it when the text itself is the artifact. If you need to review whitespace, comment placement, or trailing commas in a config file, a line diff is the right tool, because a JSON parser discards all of that. JSON has no comments, so a structural diff cannot tell you a comment moved. It never saw the comment.

The comparison ignores three things by design: key order in objects, insignificant whitespace, and the spelling of equal numbers. Everything else is a real difference.

The comparison rule, stated precisely

Structural equality is defined recursively. Two JSON values are equal when they match by this rule:

\text{eq}(a,b) = \begin{cases} a = b & \text{if both are scalars} \\ \forall k: \text{eq}(a_k, b_k) & \text{if both are objects} \\ |a| = |b| \land \forall i: \text{eq}(a_i, b_i) & \text{if both are arrays} \\ \text{false} & \text{otherwise} \end{cases}

Read a_k as the value at key k in object a, and a_i as the element at index i in array a. For objects, the two must have the same set of keys and equal values under each key; order does not appear in the rule, so it cannot matter. For arrays, the lengths must match and each position must be equal, so position does matter. For scalars, a = b means equal by value, which is why 1.0 equals 1.

The diff is the set of all leaf paths where this rule returns false. Each entry carries a path, a kind (added, removed or changed), and the old and new values where they exist.

Objects compare by key, arrays compare by index

This split is the single most important thing to internalize, so look at each half.

Objects. The comparison unions the key sets of both objects. A key present only on the right is added. A key present only on the left is removed. A key on both with unequal values is a changed entry, and the walk recurses into it.

Arrays. The comparison lines up index 0 with index 0, index 1 with index 1, and so on. It does not try to match values that moved. Insert one element at the front of a ten element array and every one of the ten positions now holds a different value, so you see ten changes plus one addition at the end. This is the footnote on the tool page made concrete, and the next widget lets you feel it.

A worked example with the demo data

Click Load sample on the tool to get these two documents. Left:

{"name":"Ada","age":36,"roles":["admin","dev"],"active":true}

Right:

{"name":"Ada","age":37,"roles":["admin","ops","dev"],"active":true,"team":"core"}

Tracing every reported difference

  1. name: both are "Ada". Equal, no entry.
  2. age: 36 on the left, 37 on the right. Changed. Path age.
  3. roles: both arrays, but lengths differ (2 versus 3), so compare by index. Index 0 is "admin" on both, equal. Index 1 is "dev" on the left and "ops" on the right, so changed at roles[1]. Index 2 does not exist on the left but is "dev" on the right, so added at roles[2].
  4. active: both true. Equal, no entry.
  5. team: absent on the left, "core" on the right. Added. Path team.

Total: four entries. One changed scalar (age), one changed array element (roles[1]), and two additions (roles[2] and team).

Notice what happened to roles. To a human, one value was inserted in the middle and nothing was removed. The index-based diff cannot know that. It reports a change at index 1 and an addition at index 2, because that is literally what the positions hold. Keep this in mind when you diff arrays whose meaning depends on order.

Given the base array [10,20,30,40,50], inserting one value at position p makes every index from p onward hold a different value. Insert at position 0 and all 5 original positions change plus 1 addition (6 entries). Insert at position 5 (the end) and 0 positions change plus 1 addition (1 entry). The number of changed entries equals 5 - p.

Reading and interpreting the result list

Every entry is a path plus a kind. Paths use dot notation for object keys and bracket notation for array indices, so user.roles[2] means "index 2 of the array under key roles under key user". The three kinds map directly to the rule:

added
The path exists on the right but not the left. Old value is absent.
removed
The path exists on the left but not the right. New value is absent.
changed
The path exists on both sides with unequal values. Both old and new are shown.

An entry is a leaf: the diff descends as far as it can before reporting. If an entire subtree is new, you get one added entry at the subtree root, not one entry per leaf inside it. That keeps the output proportional to the size of the change, not the size of the document.

Sort your thinking by kind. A pile of changed entries at consecutive array indices almost always means one insert or delete near the front, not many independent edits. Check the array lengths first.

Common mistakes

Expecting a "moved" result. No structural diff of plain JSON reports moves. A key that changed objects reads as one removal and one addition. An array element that shifted reads as a run of changes. That is faithful to the data model, not a bug.

Assuming number formatting survives. Because 1.0 equals 1 and 1e3 equals 1000, a change that only reformats a number shows nothing. If you truly need to preserve exact numeric text, JSON is the wrong container; the parser normalizes it.

Diffing invalid JSON. The comparison starts with a parse. A trailing comma or an unquoted key fails the parse, and there is nothing to compare. Validate first with the JSON Formatter & Validator if either side might be malformed.

Confusing null with absent. A key set to null is present with the value null. Removing the key entirely is different. The first reads as a changed value; the second reads as removed.

Related tools

Once you know what changed, these help with the next step. Use the JSON Formatter & Validator to pretty-print or query a document with JSONPath before comparing. If one side arrives as YAML, TOML, XML or CSV, convert it first with the data format converter, then diff the JSON. When a changed value is a string you need to unescape or re-escape, the String Escaper handles JSON string escapes. For matching keys or values by pattern rather than exact path, test expressions in the Regex Tester.

Frequently asked questions

Why does reordering object keys show no difference?

Because JSON objects are unordered maps. The RFC 8259 grammar lists members in some order, but the data model gives that order no meaning, so the structural rule compares by key, not by position. Two objects with the same keys and values are equal regardless of order.

Why is 1.0 equal to 1?

JSON numbers denote values, not text. Both 1.0 and 1 parse to the number one, so the comparison treats them as equal. The same holds for 1e3 and 1000.

Why did inserting one array element produce so many changes?

Arrays compare by index. Inserting at position p in an array of length n shifts every element from p onward, so you see n - p changed entries and one addition. Insert at the end and you see a single addition.

Does anything leave my browser?

No. The comparison runs entirely in your browser. Nothing you paste is uploaded.

Can it tell me a value moved between keys?

No. A value that appears under a new key and disappears from an old one reads as one addition and one removal. Structural diff has no concept of identity that would let it call this a move.