How JSON Diff Works: Structural Comparison vs. Text Diff

By Wesley · · 7 min read

diff and git diff compare text line by line. JSON is not line-oriented: the same data can be written with different key order, indentation or number formatting. Point a text diff at two JSON files and you get a report about formatting. A structural diff compares the values instead.

The same data, two verdicts

These two documents are equal as JSON. Only the key order differs.

a.json
{
"name": "Sunny Bean",
"wifi": true
}
b.json
{
"wifi": true,
"name": "Sunny Bean"
}
A line-based diff flags four lines. A structural diff reports no differences.

The JSON specification says an object is an unordered collection of name/value pairs, so a comparison that treats order as meaningful is answering the wrong question. The same applies to whitespace and to 1.0 versus 1.

How a structural diff walks two documents

Parse both sides, then compare recursively starting at the root:

  • Different types (string vs. number, object vs. array): report the node as changed and stop descending.
  • Two objects: take the union of their keys. A key only on the left is removed, only on the right is added; keys on both sides are compared recursively.
  • Two arrays: pair up elements using one of the strategies below, then compare each pair.
  • Two primitives: equal or changed.

Each finding carries a path to where it happened, such as menu.drinks[1].price. The path is what makes the output usable: you can read it, filter it, or turn it into a patch.

Output for a small menu change
~ menu.drinks[0].price: 5.50 → 5.99
~ menu.drinks[1].name: "Matcha Cloud" → "Lavender Dream"
+ menu.special: "Rainbow Cake"
- menu.seasonal

Arrays are the hard part

Objects have keys to line things up. Arrays only have positions, and positions shift. Insert one element at the front and every later element has a new index.

Before
applebananacherry
After
kiwiapplebananacherry
By index
kiwiapplebananacherry3 changed, 1 added
By alignment
kiwiapplebananacherry1 added
Inserting “kiwi” at the front: index-based comparison flags every position, sequence alignment flags one.
StrategyHow elements are pairedGood forWeakness
By indexElement 0 with element 0, and so onFixed-position data: coordinates, tuples, config listsAn insertion near the front makes everything after it look changed
Sequence alignment (LCS)Longest common subsequence, like a text diffOrdered lists where items are inserted or removedSlower on long arrays; a moved item shows as remove + add
By keyMatch objects on an identity field such as idRecords from a database or APIYou must know the key; fails on duplicates
What this site's tool does
The JSON Compare & Diff tool compares arrays of equal length index by index and reports each differing element with its path. When the lengths differ, it reports the whole array as one change and shows both versions. If you need to know which items were added to an unordered list, the Compare Two Lists tool answers that directly.

Small decisions about equality

  • Numbers. 1, 1.0 and 1e0 are the same number after parsing. The string "1" is not. A type change like that is usually the most important line in a diff.
  • Null versus missing. {"a": null} and {} are different documents. Some APIs treat them the same, some do not, so a diff should show it.
  • Large integers. Tools written in JavaScript parse numbers as 64-bit floats, so two different IDs above 253 can compare as equal. Keep such IDs as strings.
  • Duplicate keys. Parsers keep the last one. A diff will not warn you about the earlier value.

From a diff to a patch

A diff for humans and a patch for machines carry the same information. Two standard patch formats exist:

JSON Patch (RFC 6902): a list of operations with JSON Pointer paths
[
  { "op": "replace", "path": "/menu/drinks/0/price", "value": 5.99 },
  { "op": "add", "path": "/menu/special", "value": "Rainbow Cake" },
  { "op": "remove", "path": "/menu/seasonal" }
]
JSON Merge Patch (RFC 7396): a partial document
{
  "menu": {
    "special": "Rainbow Cake",
    "seasonal": null
  }
}

Merge Patch is easier to read and write, with two limits: null means "delete this key", so you cannot set a value to null, and arrays can only be replaced whole. JSON Patch can express anything, including edits to a single array element, at the cost of being more verbose.

On the command line

You can get most of the way with tools you already have by normalizing both files first. jq -S sorts object keys and reformats, so a text diff afterwards only shows real changes:

diff <(jq -S . a.json) <(jq -S . b.json)

This fixes key order and whitespace. It does not reorder arrays, and the output is still lines, not paths.

For a quick comparison

JSON Compare & DiffPaste two JSON documents and see added, removed and changed values with their paths.

If one of your inputs is an escaped string from a log, decode it first so the diff compares structure and not one long string.