How to compare two JSON files and find what actually changed
Compare JSON payloads by changed path instead of by line. Covers normalizing key order, jq and diff workflows, array pitfalls, and noisy fields.
You have a response from staging and a response from production, and one of them behaves differently. The files are 600 lines each, the keys come out in different orders, and a plain text diff lights up like a Christmas tree. Comparing JSON well means deciding what counts as a difference, normalizing the inputs, and then reading the result in terms of paths instead of lines. Here is a workflow that holds up.
Why a plain text diff misleads
A line diff compares characters. It does not know that {"a":1,"b":2} and {"b":2,"a":1} are the same object. JSON objects are unordered, so when two serializers emit keys in different orders, or one file is minified and the other indented, a text diff reports changes that are not changes at all.
What you usually want is a structural comparison: walk both documents together and report where a path exists in one and not the other, or where the value at a path differs. That answers the question you actually have, such as whether user.role changed, and it is insensitive to formatting.
Path notation makes reports easy to discuss. Writing $.user.role or items[1].qty gives your teammates an exact location to check in the code, and it survives copy and paste into a ticket far better than a line number does, since line numbers change as soon as the file is formatted differently. Many comparison tools use JSONPath or JSON Pointer style paths for this reason.
A small before and after example
Here are two versions of an order response. Look at them as a developer investigating a bug report that says the customer was charged the wrong amount. The list of differences is short, but they are scattered, and one of them is easy to miss.
// staging.json
{
"id": "ord_1001",
"status": "paid",
"total": 49.90,
"currency": "EUR",
"items": [
{ "sku": "A-1", "qty": 2 },
{ "sku": "B-7", "qty": 1 }
],
"updated_at": "2025-03-01T10:15:00Z"
}
// production.json
{
"id": "ord_1001",
"status": "paid",
"total": "49.90",
"currency": "EUR",
"items": [
{ "sku": "B-7", "qty": 1 },
{ "sku": "A-1", "qty": 2 }
],
"updated_at": "2025-03-01T10:15:42Z",
"discount_code": null
}Reading that diff by path
A structural comparison of the two documents produces four findings. At $.total, a number in staging is a string in production, which is the sort of type drift that causes real bugs in clients that do arithmetic. At $.updated_at the value differs, but timestamps usually change between requests, so that is noise. At $.discount_code there is an added field with a null value, which might be a new optional field in the newer build. And at $.items the elements appear in a different order.
The array case is a judgment call. If items is a list where order has meaning, such as steps or ranked results, the reorder is a real change. If it is a set of line items that the API does not promise to order, it is harmless. Compare tools differ here, and some report index by index changes, which means a reorder shows up as two changed elements. Check how yours handles arrays before you trust the output.
Normalize first with jq and diff
The simplest reliable approach on a command line is to canonicalize both files and then use an ordinary diff. The jq flag -S sorts object keys recursively, and the default output is indented consistently, so differences in whitespace and key order disappear. Arrays are not reordered, which is correct since array order is part of the data. If order should not matter for a particular array, sort it explicitly with a jq expression using a stable key such as sku.
You can also delete fields you already know are volatile before comparing. The del function removes a path from the document. This keeps the diff focused on the behavior you are investigating.
diff <(jq -S . staging.json) <(jq -S . production.json)
# Ignore volatile fields and treat items as a set keyed by sku:
norm='del(.updated_at) | .items |= sort_by(.sku)'
diff <(jq -S "$norm" staging.json) <(jq -S "$norm" production.json)
# Example output of the first command:
# 4c4
# < "total": 49.90,
# ---
# > "total": "49.90",Numbers can change during normalization
Normalizing through a parser can alter how a number is written. Depending on the tool and version, 49.90 may come back as 49.9, or 1e2 as 100. Since JSON does not specify precision, those are the same number to a parser, so this is usually fine, but it is worth remembering when the exact text matters, such as for signatures. Check what your own tool does with a quick test.
The same caution applies to very large integers, which some tools round to the nearest double before comparing. If a difference involves IDs with more than fifteen or sixteen digits, confirm it by looking at the raw text of both files.
Compare in code for automated checks
For regression tests, comparing parsed values in code beats comparing strings. Most test frameworks have a deep equality assertion that ignores key order, such as toEqual in Jest or assertEqual on dictionaries in Python, and it reports the path that differs. Parse both inputs, strip the fields you know are unstable, and then assert equality.
A sound habit is to keep a known-good response as a fixture in your repository and compare new responses against it. When an intentional API change lands, update the fixture in the same commit, so the diff shows reviewers exactly what changed in the contract.
Test code has an advantage over a one-off comparison: the list of ignored fields is written down. A reviewer can see that updated_at and request_id are excluded on purpose, and anyone who later adds a field they should not ignore will notice it in the same place. That documentation is often as valuable as the check itself, particularly when the API belongs to another team and the fixture is your only record of what you depend on.
const strip = ({ updated_at, ...rest }) => rest;
test("order response matches fixture", () => {
const actual = JSON.parse(actualText);
const expected = JSON.parse(fixtureText);
expect(strip(actual)).toEqual(strip(expected));
});Comparing visually, and a note on privacy
Sometimes you want a side-by-side view rather than a script. Compare is part of the Pro plan in View My JSON, alongside Charts, 3D, and Rich view, while viewing, Format, and Validate stay free. Whatever you use, validate both inputs first, since a truncated paste produces a confusing result.
Before you paste real responses into any tool or ticket, remove tokens, email addresses, and other personal data. Replace them with placeholders of the same type so the structure is still meaningful. A changed value is evidence of a difference and not an explanation, so once you have the list of paths, go back to the code that produces each one and confirm whether the change was intended.