Skip to content
devtoolbox.cc

How to compare two JSON files (and why text diff gets it wrong)

· 7 min read

Open the JSON Diff tool

You have two versions of the same API response, one from staging and one from production, and they “should be the same”. git diff shows 300 changed lines. After ten minutes of scrolling you find that the only real difference is one field, and the rest is noise: keys in a different order, 1.0 written as 1, and a different indentation.

Text diff compares lines. JSON is a tree. This article explains how a structural JSON diff works, where the hard problems are (arrays, mostly), and how to turn a diff into a patch you can apply. You can follow along in the JSON Diff tool, which compares documents in the browser without uploading them.

Why text diff produces noise

Take these two documents:

{ "id": 7, "name": "Ann", "tags": ["a", "b"], "score": 1.0 }
{
  "score": 1,
  "tags": ["a", "b"],
  "name": "Anna",
  "id": 7
}

To a text diff almost every line is different. As data, only name changed. The rest falls into three kinds of noise:

  • Key order. JSON objects are unordered, so {"a":1,"b":2} and {"b":2,"a":1} are the same object. Serializers reorder keys all the time (Python dicts before 3.7, Go maps, many database drivers).
  • Formatting. Whitespace and indentation mean nothing to a parser.
  • Number spelling. 1.0, 1 and 1e0 are the same number.

The common workaround is to normalise both files and then diff text:

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

jq -S sorts keys and jq . reformats, which removes the first two kinds of noise. It works for small objects. It falls apart once arrays change, and it still reports numbers by their spelling.

Structural diff: compare the trees

A structural diff walks both documents at the same time and reports three kinds of change:

  • added: a key or array item exists only in the new document
  • removed: it exists only in the old one
  • changed: it exists in both, with a different value or type

Each change has a path, for example $.orders[2].status. For objects this is easy: match keys by name, recurse into shared keys, report the rest. The pasted example above yields exactly one change in the JSON Diff tool: ~ $.name: "Ann" → "Anna".

Types matter. "1" (a string) and 1 (a number) are different values, and a change from an object to an array is reported as one change of the whole value, not a hundred field changes.

The hard part: arrays

Arrays have an order, and they are where diff tools disagree. Take a list of tags:

before: ["api", "json", "diff"]
after:  ["api", "compare", "json", "diff"]

By position, item 1 changed from json to compare, item 2 from diff to json, and item 3 was added. That’s three changes for one insertion.

Smart matching uses the longest common subsequence (LCS), the same algorithm line-based diff uses. It finds that api, json and diff survived and reports one change: compare was inserted at index 1.

Neither is always right, which is why the JSON Diff tool lets you choose:

Mode Use it when
Smart (LCS) Order matters and items get inserted or removed (logs, steps, lists)
By position Arrays are fixed-length tuples, such as coordinates [lat, lon]
Ignore order Arrays are really sets: tags, permissions, feature flags
Match by key Arrays of objects with an id: users, orders, line items

Match by key

The last mode matters most for API data. Suppose an endpoint returns users sorted by “last active”, and between two calls user 2 made a request:

[
  { "id": 1, "name": "a" },
  { "id": 2, "name": "b" }
]
[
  { "id": 2, "name": "B" },
  { "id": 1, "name": "a" }
]

Positional and LCS modes both see the order change and report churn. Matching objects by id pairs user 1 with user 1 and user 2 with user 2, and reports only the real change: $[1].name → $[0].name: "b" → "B". The two paths differ because the same user sits at a different index on each side.

Matching by key needs a field that is present and unique in every item. If any item lacks it, a good tool falls back to another mode and tells you, instead of producing a confident but wrong result.

Duplicates in “ignore order” mode

“Ignore order” is usually implemented as a multiset, not a strict set. [1, 1, 2] against [1, 2] reports one removed 1. If duplicates are meaningless in your data, deduplicate before comparing.

Numbers deserve exact comparison

A structural diff has to decide when two numbers are equal. Comparing them as JavaScript numbers breaks on large integers:

9007199254740993 === 9007199254740992; // true after JSON.parse

Both literals round to the same double, so a naive diff says two different ids are equal. The JSON Diff tool compares numbers by their decimal value, so 1.0 equals 1 but 9007199254740993 and 9007199254740992 are reported as different.

From diff to patch: RFC 6902

Seeing the differences is one half. Often you also want to apply them somewhere else, for example to send only the changes to an API. That’s what JSON Patch (RFC 6902) is for:

[
  { "op": "replace", "path": "/name", "value": "Anna" },
  { "op": "add", "path": "/tags/1", "value": "compare" },
  { "op": "remove", "path": "/legacyId" }
]

Paths are JSON Pointers (RFC 6901), not JSONPath. A / inside a key is written as ~1 and a ~ as ~0, so the key a/b becomes /a~1b. Operations run in order, and each array index refers to the array as it is at that moment, after the earlier operations. Getting that bookkeeping right is the fiddly part of generating patches. The diff tool checks it by applying its own patches to hundreds of random documents in its test suite.

A patch only makes sense when array order matters. In “ignore order” or “match by key” mode there is no well-defined position to add or remove at, so the tool disables the export there.

There’s also JSON Merge Patch (RFC 7386), which is simpler: you send a partial document, and null means “delete this key”. It can’t express array edits or set a value to null, so it suits simple partial updates and not general diffs.

Practical recipes

Comparing API responses in tests: Snapshot tests that compare raw text fail on every key reordering. Compare parsed values with a deep-equal assertion instead, and ignore volatile fields (timestamps, request ids) explicitly.

Reviewing config changes: For a large JSON config in a pull request, run a structural diff locally and paste the change list into the PR description. Reviewers read “3 changes” faster than a 300-line text diff.

Finding what changed in a big document: When the diff says $.items[418].price changed, open the document in the JSON editor and jump to that path. A JSONPath query such as $.items[?@.price > 100] helps check whether the change affected other items too.

Summary

Text diff answers “which lines changed”. For JSON you almost always want “which values changed”. Compare the trees, choose an array strategy that matches what your arrays mean, compare numbers exactly, and export a JSON Patch when the changes need to go somewhere. Paste your two documents into the JSON Diff tool to see the difference between the two approaches on your own data.

← All posts