Comparing two JSON files should answer one practical question: what data actually changed? Not which object keys were printed in a different order. Not whether one file uses two spaces and the other is minified. Not whether a serializer wrapped an array differently after a deploy.
That distinction matters when you are reviewing a config change, checking an API response after a release, comparing webhook payloads, or trying to understand why a snapshot test failed. A plain text diff is useful for code. For JSON, it is often too noisy unless you normalise the documents first.
The reliable workflow is:
- Parse both files.
- Sort object keys recursively.
- Format both documents consistently.
- Compare the formatted lines for a readable side-by-side diff.
- Walk the parsed structures for field-level counts such as added, removed, changed, and unchanged.
That is the same shape used by JSON Diff on fixjson.org: parse first, ignore object key order, then show the changes a human can act on.
The Fast Answer
If you only need a command-line check and both files are valid JSON, normalise first:
diff -u <(jq -S . before.json) <(jq -S . after.json)
jq -S . parses the JSON, sorts object keys, and pretty-prints the output. That removes most false positives from whitespace and object key order.
Use a JSON-aware visual diff when:
- You need a side-by-side view for a pull request or incident note.
- You want summary counts of fields added, removed, and changed.
- You need to compare pasted API responses, not files already on disk.
- You want YAML comparison with the same semantics.
- One document might be invalid and you need a parse error before comparing.
Use a plain text diff only when formatting and key order are part of what you are reviewing. That is rare for JSON data, but it can matter if you are auditing generated output byte-for-byte.
Why Plain Text Diff Gets JSON Wrong
JSON is text on disk, but the meaning is a data structure. That mismatch creates three common false positives.
Reordered Object Keys
JSON objects are unordered collections of name/value pairs. These two documents carry the same data:
// before.json
{ "name": "Ada", "plan": "pro", "active": true }
// after.json
{ "active": true, "name": "Ada", "plan": "pro" }
A line diff may mark the whole object as changed. A JSON diff should report no data change.
This shows up constantly after code changes like:
- switching JSON serializers
- running a formatter with sorted keys
- rebuilding fixtures from a map or dictionary
- moving from one language runtime to another
Formatting Noise
Whitespace outside strings is insignificant in JSON:
{"active":true,"plan":"pro"}
and:
{
"active": true,
"plan": "pro"
}
parse to the same value. A text diff sees many line changes. A JSON-aware diff sees none.
Real Value Changes Hidden Inside Noise
The worst case is not a false positive. It is when the real change is buried inside hundreds of formatting changes:
// before
{ "user": { "id": 42, "plan": "pro", "quota": 1000 } }
// after, regenerated with different key order
{
"user": {
"quota": 1000,
"id": 42,
"plan": "team"
}
}
The important change is $.user.plan: pro became team. Normalising first makes that visible.
The Pipeline A JSON Diff Tool Should Use
A good JSON comparison tool does two related jobs:
- It produces a readable line diff for humans.
- It produces a semantic diff so the summary counts and paths are not fooled by formatting.
The implementation can be simple, but the order matters.
Step 1: Parse Both Documents
Parse first. If either document is invalid, stop and report the parse error. Diffing broken JSON as raw text often sends you chasing symptoms instead of the actual problem.
In JavaScript, the strict shape looks like this:
const before = JSON.parse(beforeText);
const after = JSON.parse(afterText);
On fixjson.org, the JSON Diff tool uses the site's parser and runs the diff pipeline in a Web Worker, so a large comparison does not freeze the editor while you type or click Compare. YAML mode parses YAML first, then reuses the same structural comparison logic.
If a file does not parse, repair or validate it before comparing:
- trailing comma: fix the comma, then diff
- single quotes or unquoted keys: convert to valid JSON first
- truncated API response: re-fetch the body before trusting any diff
JSON Fix is the better first stop when one side is malformed. A diff is only meaningful after both sides are parseable.
Step 2: Normalise For Display
For a readable side-by-side view, re-serialize both parsed values with the same rules:
- sort object keys recursively
- keep array order unchanged
- use consistent indentation
- output one stable string per side
function sortJsonKeys(value) {
if (Array.isArray(value)) {
return value.map(sortJsonKeys);
}
if (value !== null && typeof value === 'object') {
return Object.keys(value)
.sort((a, b) => a.localeCompare(b))
.reduce((acc, key) => {
acc[key] = sortJsonKeys(value[key]);
return acc;
}, {});
}
return value;
}
function formatForDiff(value) {
return JSON.stringify(sortJsonKeys(value), null, 2);
}
After normalisation, the two reordered examples become identical:
{
"active": true,
"name": "Ada",
"plan": "pro"
}
One important boundary: do not sort arrays by default. Object key order is not meaningful in JSON, but array order usually is. Sorting arrays would hide real changes in ordered data such as logs, search results, navigation items, and priority rules.
Step 3: Build A Semantic Diff Tree
The line view tells a human what changed visually. The semantic tree tells the tool what changed structurally.
A semantic diff walks both parsed values at the same path:
function diffValue(key, path, before, after) {
if (before === undefined) {
return { key, path, status: 'added', after };
}
if (after === undefined) {
return { key, path, status: 'removed', before };
}
if (isPlainObject(before) && isPlainObject(after)) {
const keys = Array.from(
new Set([...Object.keys(before), ...Object.keys(after)])
).sort((a, b) => a.localeCompare(b));
const children = keys.map((childKey) =>
diffValue(childKey, `${path}.${childKey}`, before[childKey], after[childKey])
);
return {
key,
path,
status: children.every((child) => child.status === 'unchanged')
? 'unchanged'
: 'changed',
children,
};
}
if (Array.isArray(before) && Array.isArray(after)) {
const length = Math.max(before.length, after.length);
const children = Array.from({ length }, (_, index) =>
diffValue(String(index), `${path}[${index}]`, before[index], after[index])
);
return {
key,
path,
status: children.every((child) => child.status === 'unchanged')
? 'unchanged'
: 'changed',
children,
};
}
return deepEqual(before, after)
? { key, path, status: 'unchanged', before, after }
: { key, path, status: 'changed', before, after };
}
That produces useful paths:
| Path | Before | After | Status |
|---|---|---|---|
$.user.plan |
"pro" |
"team" |
changed |
$.user.quota |
1000 |
2000 |
changed |
$.features.betaSearch |
missing | true |
added |
$.deprecated |
"legacy" |
missing | removed |
The summary row comes from walking this tree and counting leaves. Parent objects can be marked changed because a child changed, but the useful count is usually at the leaf level. "1 changed" should mean one value changed, not every parent container along the path.
Step 4: Run A Line Diff On The Normalised Output
Once both sides are formatted the same way, a line diff becomes useful again.
The classic approach is Longest Common Subsequence (LCS): find the longest ordered set of lines shared by both files, then mark everything else as added or removed.
Example:
Before: [
' "active": true,',
' "name": "Ada",',
' "plan": "pro"'
]
After: [
' "active": true,',
' "name": "Ada",',
' "plan": "team"'
]
LCS:
[
' "active": true,',
' "name": "Ada",'
]
The resulting operations are:
same "active": true
same "name": "Ada"
deleted "plan": "pro"
added "plan": "team"
A side-by-side viewer can then pair the adjacent delete/add as a modified row.
Step 5: Pair Delete/Add Blocks Into Modified Rows
Raw LCS output only has three operations: same, delete, add. That is technically correct, but not pleasant to review. Humans expect a one-line value change to appear as one modified row:
left: "plan": "pro"
right: "plan": "team"
The pairing step collects adjacent delete/add runs and zips them:
const deleted = [];
const added = [];
while (ops[i] && ops[i].type !== 'same') {
if (ops[i].type === 'del') deleted.push(ops[i].line);
else added.push(ops[i].line);
i++;
}
const pairs = Math.min(deleted.length, added.length);
for (let index = 0; index < pairs; index++) {
rows.push({
type: 'modified',
left: deleted[index],
right: added[index],
});
}
If there are extra deleted lines, they stay deleted. If there are extra added lines, they stay added. That keeps large object insertions readable without pretending every added line replaced an old line.
Performance Details That Matter In A Browser
The straightforward LCS dynamic-programming table is m * n, where m is the number of lines on the left and n is the number of lines on the right.
For small and medium JSON files, that is fine. For huge files, it can chew memory quickly.
Practical improvements:
- Trim common prefixes and suffixes first. If the first 400 lines and last 200 lines are identical, only diff the changed middle.
- Use a flat typed array. An
Int32Arrayis much cheaper than a nested JavaScript array of boxed numbers. - Set a cutoff. In fixjson.org's implementation, if the changed middle would require more than
2_000_000matrix cells, the tool falls back to showing the middle as a replace block instead of building an enormous table. - Run in a Web Worker. The UI stays responsive while parsing, formatting, and diffing run off the main thread.
The cutoff is not a theoretical purity move. It is a product decision: a slightly less elegant diff is better than a frozen browser tab when someone pastes a massive fixture.
For very large repository-scale diffs, use dedicated file tools or a streaming diff strategy. For pasted API responses, configs, fixtures, and webhook payloads, the parse-normalise-LCS pipeline is usually the right balance.
Comparing Arrays: The Part You Should Decide Deliberately
Arrays are ordered in JSON, so the safest default is positional comparison:
// before
[
{ "id": "a", "enabled": true },
{ "id": "b", "enabled": false }
]
// after
[
{ "id": "b", "enabled": false },
{ "id": "a", "enabled": true }
]
A positional semantic diff reports changes at [0] and [1], even though the set of objects is the same. That is not necessarily wrong. In some JSON files, order is the data: menu items, firewall rules, search ranking, migration steps, and log events all depend on sequence.
If array order is meaningless in your domain, normalise the arrays before diffing. For example, sort arrays of objects by a stable id:
function sortArraysById(value) {
if (Array.isArray(value)) {
return value
.map(sortArraysById)
.sort((a, b) => {
const left = typeof a === 'object' && a !== null ? a.id : undefined;
const right = typeof b === 'object' && b !== null ? b.id : undefined;
return String(left ?? '').localeCompare(String(right ?? ''));
});
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [key, sortArraysById(child)])
);
}
return value;
}
Do this only when you know the array is a set. A generic online JSON diff should not guess.
Deep Equal vs JSON Diff
Sometimes you do not need a diff at all. You only need to know whether two values are equal.
Use deep equality for:
- test assertions
- cache invalidation
- "did anything change?" checks
- fast guard clauses before doing more expensive work
Use a structural JSON diff for:
- code review
- debugging API regressions
- release notes
- customer support investigation
- generating a patch
- explaining config drift to another person
deepEqual(before, after) gives a boolean. A diff gives a report.
Turning A Diff Into JSON Patch
Once you have a semantic tree, you can emit JSON Patch operations (RFC 6902):
[
{ "op": "replace", "path": "/user/plan", "value": "team" },
{ "op": "add", "path": "/features/betaSearch", "value": true },
{ "op": "remove", "path": "/deprecated" }
]
Patch paths use JSON Pointer escaping:
~becomes~0/becomes~1
So a key named a/b becomes /a~1b, not /a/b.
JSON Patch is useful when you need a portable update document for an HTTP PATCH request or a replayable migration. If you only need to show a human what changed, a side-by-side diff is easier to read.
For the overlay-style alternative where null means delete, see JSON Patch vs JSON Merge Patch.
Edge Cases Worth Knowing
Invalid JSON
A JSON diff cannot compare meaningfully until both sides parse. Repair first, then compare. If you compare raw broken text, you may hide the original parse error.
Duplicate Object Keys
JSON parsers generally keep the last value for a duplicate key:
{ "plan": "pro", "plan": "team" }
After parsing, only "team" remains. If duplicate keys are part of what you need to audit, use a validator or parser that reports duplicates before diffing.
Large Integers
The fixjson.org parser stores numbers as JavaScript numbers. That means integers beyond Number.MAX_SAFE_INTEGER can lose precision:
9007199254740993
may round during parsing. If you compare IDs, ledger values, or snowflake-style identifiers, store them as strings when exact digit preservation matters.
null vs Missing
These are different:
{ "deletedAt": null }
and:
{}
A semantic diff should report the first as a present key with a null value and the second as a removed key. This distinction matters for APIs where null means "explicitly empty" and missing means "leave unchanged."
Type Changes
"42" and 42 are not the same JSON value. A semantic diff should report a type-changing value change even if they look similar in a UI.
Frequently Asked Questions
How do I compare two JSON files?
Parse both files, sort object keys recursively, format them consistently, then compare the normalised output. For a visual result, use JSON Diff, which also shows added, removed, changed, and unchanged field counts.
Why does a plain text diff not work well for JSON?
Because object key order and whitespace do not change the meaning of JSON. A text diff can flag a reordered but equivalent object as changed. A JSON-aware diff parses first, so it can ignore that noise.
What is a semantic JSON diff?
A semantic JSON diff compares parsed values at paths such as $.user.plan or $.items[2].id. It reports value-level changes instead of only showing changed text lines.
Should arrays be compared by order or by ID?
The safe default is by order, because JSON arrays are ordered. If your arrays represent unordered records with stable IDs, sort those arrays by ID before diffing. A generic tool should not assume that for you.
Can comparing JSON lose numeric precision?
Yes. JavaScript-based parsers store JSON numbers as IEEE 754 numbers, so very large integers can lose precision. Treat exact identifiers and high-precision values as strings before diffing.
Can I turn a JSON diff into a JSON Patch?
Yes. A semantic diff can emit add, remove, and replace operations using JSON Pointer paths. Use JSON Patch when you need a machine-readable update; use a side-by-side diff when a human needs to review the change.
Try The JSON Diff Tool
JSON Diff on fixjson.org follows the workflow above: it parses both documents, sorts object keys, formats each side consistently, builds summary counts from the parsed structure, and shows a side-by-side line diff. It supports JSON and YAML, and the comparison runs locally in your browser.
- JSON Diff — compare two JSON or YAML documents side by side
- JSON Patch vs JSON Merge Patch — choose the right update format after you find a difference
- RFC 8785: JSON Canonicalization (JCS) — stable JSON canonicalization for signatures and hashes
- JSON Fix — repair invalid JSON before diffing
- Handling broken JSON in JavaScript — what to do when one side will not parse
- JSON Tree Viewer — inspect one large JSON document before comparing it
Sources
- RFC 8259 — the JSON data format and object/member model
- RFC 6901 — JSON Pointer path escaping
- RFC 6902 — JSON Patch operations
- RFC 8785 — JSON Canonicalization Scheme
- Neil Fraser — Diff Strategies — practical background on line diff algorithms
Last reviewed July 2026.