Comparing two JSON documents sounds like a job for diff, until the output is full of changes that are not changes at all. JSON is data, not text: two files can differ on every line and still hold exactly the same values. This guide shows why text diffs mislead, what a meaningful JSON comparison should report, and how to compare JSON in the browser, on the command line and in code.
Examples were run with diff (macOS), jq 1.7.1, Node.js 24.21 and Python 3.9.6, and the JSON diff tool on this site.
Why a text diff is the wrong tool
These two files contain identical data. Only the formatting and key order differ:
// a.json
{"id": 42, "name": "Ada", "roles": ["admin", "editor"], "active": true}
// b.json
{
"name": "Ada",
"id": 42,
"active": true,
"roles": ["admin", "editor"]
}A line-based diff reports that everything changed:
$ diff a.json b.json
1c1,6
< {"id": 42, "name": "Ada", "roles": ["admin", "editor"], "active": true}
---
> {
> "name": "Ada",
> "id": 42,
> "active": true,
> "roles": ["admin", "editor"]
> }A structural comparison parses both documents first and compares values, so it reports no differences at all. That is the behaviour you want for API responses, configuration files and test fixtures, where serializers are free to change whitespace and key order.
What should count as a difference
| Situation | Different? | Why |
|---|---|---|
| Whitespace, indentation, line breaks | No | Not part of the data. |
| Object key order | No | JSON objects are unordered (RFC 8259). |
1 vs 1.0 | Usually no | Same number once parsed in JavaScript and Python. |
| Array element order | Yes, by default | Arrays are ordered. Whether order matters is up to your API (see below). |
Missing key vs null | Yes | Many APIs treat an absent field as "unchanged" and null as "cleared". |
42 vs "42" | Yes | A type change. Typed clients and strict validators break on it. |
true vs 1 | Yes | Different JSON types — but see the Python gotcha below. |
A realistic example
Here is an order from an API before and after a change:
// old.json
{"order": {"id": 9001, "status": "paid", "total": 42.5,
"items": [{"sku": "A1", "qty": 2}, {"sku": "B7", "qty": 1}],
"coupon": null, "updatedAt": "2026-10-20T08:00:00Z"}}
// new.json
{"order": {"id": "9001", "status": "shipped", "total": 42.5,
"items": [{"sku": "C3", "qty": 1}, {"sku": "A1", "qty": 2}, {"sku": "B7", "qty": 1}],
"updatedAt": "2026-10-23T10:15:00Z", "tracking": "1Z999"}}Paste them into the JSON diff tool and its copyable report lists every change by path:
~ $.order.id: 9001 -> "9001"
~ $.order.status: "paid" -> "shipped"
~ $.order.items[0].sku: "A1" -> "C3"
~ $.order.items[0].qty: 2 -> 1
~ $.order.items[1].sku: "B7" -> "A1"
~ $.order.items[1].qty: 1 -> 2
+ $.order.items[2]: {"sku":"B7","qty":1}
- $.order.coupon: null
~ $.order.updatedAt: "2026-10-20T08:00:00Z" -> "2026-10-23T10:15:00Z"
+ $.order.tracking: "1Z999"Reading it top to bottom:
$.order.idchanged from a number to a string. The table view labels this Type changed. This is the most likely cause of client breakage in the whole diff.couponwasnulland is now absent — reported as removed, not as unchanged.- The
itemslines look alarming, but only one item was added at the front. Because arrays are compared position by position, every following element appears changed. updatedAtchanged, as it does on every write. That is noise for most comparisons.
When array order does not matter
If an array is really a set — tags, permissions, line items identified by a SKU — sort it by a stable key before comparing. In JavaScript:
const sortItems = (o) => ({
...o,
order: { ...o.order, items: [...o.order.items].sort((a, b) => a.sku.localeCompare(b.sku)) },
});Running the same comparison on the sorted documents shrinks the six misleading item changes to the one real change:
~ $.order.id: 9001 -> "9001"
~ $.order.status: "paid" -> "shipped"
+ $.order.items[2]: {"sku":"C3","qty":1}
- $.order.coupon: null
~ $.order.updatedAt: "2026-10-20T08:00:00Z" -> "2026-10-23T10:15:00Z"
+ $.order.tracking: "1Z999"Only sort arrays whose order genuinely carries no meaning. For ordered data such as steps, ranked results or coordinates, a reorder is a real change and should be reported.
Comparing JSON on the command line
jq -S sorts keys and pretty-prints, which turns any JSON into a canonical text form. A normal diff of the canonical forms then shows only real differences. For the two files at the top of this guide it prints nothing and exits with status 0:
$ diff <(jq -S . a.json) <(jq -S . b.json)
$ echo $?
0jq can also sort arrays and drop volatile fields before comparing:
diff <(jq -S '.order.items |= sort_by(.sku) | del(.order.updatedAt)' old.json) \
<(jq -S '.order.items |= sort_by(.sku) | del(.order.updatedAt)' new.json)The output is still line-based, so changes appear as lines rather than paths, but it works in any shell script or CI job. diff exits with status 1 when the inputs differ, which makes it easy to fail a build.
Comparing JSON in code
JavaScript (Node.js)
import { isDeepStrictEqual } from "node:util";
isDeepStrictEqual(JSON.parse(a), JSON.parse(b)); // true for a.json and b.json
isDeepStrictEqual({ a: true }, { a: 1 }); // false
isDeepStrictEqual(JSON.parse('{"n":1.0}'), JSON.parse('{"n":1}')); // trueisDeepStrictEqual ignores key order and distinguishes types. It answers only "equal or not"; for a list of changes, use a diff library or the tool. In tests, expect(actual).toEqual(expected) in Vitest or Jest prints a structural diff on failure.
Python
import json
json.load(open("a.json")) == json.load(open("b.json")) # True
json.loads('{"a": true}') == json.loads('{"a": 1}') # True (!)
json.loads('{"n": 1.0}') == json.loads('{"n": 1}') # TrueComparing parsed dictionaries with == ignores key order, which is what you want. But in Python True == 1, so a boolean that became the number 1 (or false that became 0) compares as equal. If that matters — and in API contracts it often does — check types explicitly or compare json.dumps(obj, sort_keys=True) strings, which render true and 1 differently.
Ignoring fields that always change
Timestamps, request IDs, ETags and generated IDs differ on every response and drown out real changes. Remove them before comparing — with del(...) in jq as shown above, or by deleting the keys in code — rather than training yourself to skim past them. Keep the list of ignored fields short and explicit, so a real change to one of them is not hidden forever.
Common uses
- API regressions: save a known-good response and diff new responses against it after each deploy. How to debug API responses covers the rest of that workflow.
- Staging vs production: the same request against both environments often reveals config or feature-flag differences.
- Configuration review: compare
package.json, infrastructure or feature-flag files without formatting noise. - Snapshot tests: when a snapshot fails, a structural diff shows the one field that changed.
If either document fails to parse, fix it first: the JSON formatter shows the exact line and column of the error.