You have two API responses that should be identical. You run diff a.json b.json and get 400 lines of red and green. Then you notice the data is the same: one file just lists the keys in a different order.
Text diff compares characters. JSON is a data format, and two files can mean exactly the same thing while looking completely different. This post covers why, and three ways to compare JSON by meaning instead of by text.
What counts as "the same" JSON?
RFC 8259 defines a JSON object as an unordered collection of name/value pairs, and an array as an ordered sequence of values. That gives three rules:
-
{"a":1,"b":2}and{"b":2,"a":1}are the same object. -
[1,2]and[2,1]are different arrays. - Whitespace between tokens never matters.
A text diff gets all three wrong. It flags reordered keys and reformatted whitespace as changes, which is noise, and it can't tell you that a change inside a nested object happened at $.address.city.
Option 1: sort keys, then diff (jq)
The quickest fix on the command line is to normalise both files first. jq -S sorts object keys recursively and pretty-prints:
diff <(jq -S . a.json) <(jq -S . b.json)
This removes key-order and whitespace noise, and a normal diff of the result is readable. Two limits:
- Array order is still significant, which is usually correct.
- You still get a line diff, not a list of paths that changed.
Option 2: Python, where equality already works
If you only need to know whether two documents are equal, Python's json module already compares by meaning:
import json
a = json.loads('{"a":1,"b":[1,2]}')
b = json.loads('{"b":[1,2],"a":1.0}')
print(a == b) # True: key order ignored, 1 == 1.0
print(json.loads('[1,2]') == json.loads('[2,1]')) # False: array order matters
For a readable list of differences, the DeepDiff library is a well-known option.
Option 3: a small path-based diff in JavaScript
When you want to know what changed and where, a recursive function is about 30 lines. This one is tested and returns a path for each difference:
function typeOf(v) {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
return typeof v;
}
function diff(a, b, path = "$") {
if (a === b) return [];
const ta = typeOf(a), tb = typeOf(b);
if (ta !== tb) return [{ path, kind: "type", from: a, to: b }];
if (ta === "array") {
const out = [];
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
const p = `${path}[${i}]`;
if (i >= a.length) out.push({ path: p, kind: "added", to: b[i] });
else if (i >= b.length) out.push({ path: p, kind: "removed", from: a[i] });
else out.push(...diff(a[i], b[i], p));
}
return out;
}
if (ta === "object") {
const out = [];
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
const p = `${path}.${k}`;
if (!Object.hasOwn(a, k)) out.push({ path: p, kind: "added", to: b[k] });
else if (!Object.hasOwn(b, k)) out.push({ path: p, kind: "removed", from: a[k] });
else out.push(...diff(a[k], b[k], p));
}
return out;
}
return [{ path, kind: "changed", from: a, to: b }];
}
Running it on two objects that differ in key order, one array item, one nested value and one new key:
const a = JSON.parse('{"name":"Ada","tags":["x","y"],"address":{"city":"London","zip":"N1"}}');
const b = JSON.parse('{"address":{"zip":"N1","city":"Paris"},"tags":["x","z"],"name":"Ada","admin":true}');
console.log(diff(a, b));
gives exactly three differences, and the reordered keys are not among them:
$.tags[1] changed "y" -> "z"
$.address.city changed "London" -> "Paris"
$.admin added true
(Object.hasOwn needs a recent runtime. It is available in current Node and all modern browsers.)
The array problem
Arrays are where most "false differences" come from. Suppose an API returns users in a different order on each call:
const before = [{ id: 1, n: "a" }, { id: 2, n: "b" }];
const after = [{ id: 2, n: "b" }, { id: 1, n: "c" }];
Compared by position, that reports four differences, because every item shifted. Only one thing really changed: user 1's n went from "a" to "c". The fix is to index the array by a stable key before comparing:
const byId = (list, key = "id") =>
Object.fromEntries(list.map((item) => [item[key], item]));
diff(byId(before), byId(after));
// -> one difference, at the entry for id 1, field n
This only works if the items have a unique identifier. For arrays of plain values (["x","y"]), the honest options are to sort both sides first or to accept that order is part of the data.
Gotchas that bite in production
-
Large numbers. JavaScript parses every JSON number as a 64-bit float.
JSON.parse("9007199254740993")returns9007199254740992, so two different IDs can compare as equal. If your data has 64-bit IDs, compare them as strings or use a BigInt-aware parser. -
Duplicate keys. RFC 8259 says names within an object should be unique, but parsers accept duplicates.
JSON.parse('{"a":1,"a":2}')quietly keeps the last value, so one side of a comparison can lose data before your code ever sees it. -
1versus1.0. JavaScript and Python treat them as equal after parsing. A text diff doesn't, and a strict schema might not either. -
Type changes.
"5"(string) and5(number) are different values, and this is a common cause of real API regressions. Thedifffunction above reports it as atypedifference.
When you just want to look at a diff
For one-off checks I got tired of writing scripts, so I built a free compare JSON online page. Disclosure: I'm the author. You paste two documents, it ignores key order and whitespace, and it lists what's different. It runs in your browser and doesn't upload what you paste, which matters when the JSON is an API response with real data in it.
For anything automated, such as tests or CI, use one of the code options above, since they run in your pipeline and can be asserted on.
Summary
- Text diff is the wrong tool because JSON object order and whitespace carry no meaning.
-
jq -Splusdiffis the fastest command-line fix. - Python's
==already compares by meaning; DeepDiff gives readable output. - A 30-line recursive function gives path-based differences in JavaScript.
- Match arrays by a stable id when order isn't meaningful, and watch out for big numbers and duplicate keys.
What do you use to compare JSON in your workflow? I'd like to hear about edge cases I haven't covered.
Top comments (0)