How JSON Diff Works: Structural Comparison vs. Text Diff
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.
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.
~ 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.
| Strategy | How elements are paired | Good for | Weakness |
|---|---|---|---|
| By index | Element 0 with element 0, and so on | Fixed-position data: coordinates, tuples, config lists | An insertion near the front makes everything after it look changed |
| Sequence alignment (LCS) | Longest common subsequence, like a text diff | Ordered lists where items are inserted or removed | Slower on long arrays; a moved item shows as remove + add |
| By key | Match objects on an identity field such as id | Records from a database or API | You must know the key; fails on duplicates |
Small decisions about equality
- Numbers.
1,1.0and1e0are 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:
[
{ "op": "replace", "path": "/menu/drinks/0/price", "value": 5.99 },
{ "op": "add", "path": "/menu/special", "value": "Rainbow Cake" },
{ "op": "remove", "path": "/menu/seasonal" }
]{
"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.