Skip to content
DevPenh

How to Compare Two JSON Files (and What Counts as a Difference)

Why text diffs mislead for JSON, how key order, array order, null and type changes should be treated, and how to compare JSON with jq, Node.js and Python.

Last reviewed

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

SituationDifferent?Why
Whitespace, indentation, line breaksNoNot part of the data.
Object key orderNoJSON objects are unordered (RFC 8259).
1 vs 1.0Usually noSame number once parsed in JavaScript and Python.
Array element orderYes, by defaultArrays are ordered. Whether order matters is up to your API (see below).
Missing key vs nullYesMany APIs treat an absent field as "unchanged" and null as "cleared".
42 vs "42"YesA type change. Typed clients and strict validators break on it.
true vs 1YesDifferent 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.id changed 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.
  • coupon was null and is now absent — reported as removed, not as unchanged.
  • The items lines look alarming, but only one item was added at the front. Because arrays are compared position by position, every following element appears changed.
  • updatedAt changed, 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 $?
0

jq 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}')); // true

isDeepStrictEqual 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}')       # True

Comparing 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.

Part of the API debugging series. Start with How to Debug API Responses: A Practical Workflow.