← All articles

Compare Two JSON Files with jq, Semantic Diff, and JSON Patch

Compare JSON by normalizing keys and whitespace, use jq to inspect and transform changes, and choose JSON Patch or JSON Merge Patch when the diff must become an API update.

Comparing two JSON files should answer a practical question: what data actually changed? Not what object keys were printed out in reverse order. Not that one file is minified and the other has two spaces. Not if a serializer wrapped an array differently after a deploy.

This difference matters when you are reviewing a config change, checking an API response after a release, comparing webhook payloads, or trying to figure out why a snapshot test failed. A plain text diff is handy for code. JSON is often too noisy unless you normalize the docs first.

The reliable workflow is:

  1. Parse both files.
  2. Sort object keys recursively.
  3. Format both documents consistently.
  4. Compare the formatted lines for a readable side-by-side diff.
  5. 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.

Only use a plain text diff if you are looking at formatting and key order. This is uncommon for JSON data, but it can be important if you are auditing generated output byte-for-byte.

jq Cheat Sheet for Inspection and Comparison

jq parses JSON before it filters, so it is safer and more expressive than grep for nested data. These commands cover most comparison prep:

jq . data.json                         # validate and pretty-print
jq -S . data.json                      # recursively sort object keys
jq -c . data.json                      # compact output
jq -r '.data.token // empty' data.json # raw string, no JSON quotes
jq '.items[] | select(.active)' data.json
jq '{id, name, plan: (.plan // "free")}' data.json
jq empty data.json                     # validation only

For arrays of records, you can normalize a domain-defined set by a stable key before diffing:

jq -S '.items |= sort_by(.id)' before.json > before.normalized.json
jq -S '.items |= sort_by(.id)' after.json  > after.normalized.json
diff -u before.normalized.json after.normalized.json

Perform this only when order is meaningless. Sorting firewall rules, migrationsteps, search rankings or event logs can hide a real behavioral change.

Use --arg and --argjson to pass shell values instead of interpolating them into a filter:

jq --arg id "$USER_ID" '.items[] | select(.id == $id)' data.json
jq --argjson limit 10 '.items[:$limit]' data.json

For redaction and updates:

jq 'del(.headers.authorization, .cookie)' response.json
jq '.settings.theme = "dark"' config.json

jq writes the transformed JSON to standard output; it does not safely edit a file in place. Write a temporary file, verify success, then replace the original. Also remember that -r deliberately removes JSON string quoting, so its output may contain newlines or shell-significant text. Keep untrusted data quoted when passing it to another command.

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

Worst case is not a false negative. It is when the real change is concealed in 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

Initial parse. If either document is invalid, abort and signal the parse error. When you diff broken JSON as plain text, you often find yourself chasing symptoms instead of the real 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: don’t sort arrays by default. JSON does not care about object key ordering, but array ordering usually does. If you sort arrays, you may be hiding actual changes to ordered data such as logs, search results, navigation items, priority rules, and more.

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 method is Longest Common Subsequence (LCS): find the longest set of lines that appear in the same order in both files, and 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

The raw LCS output has only three operations: same, delete, add. That is technically correct, but not pleasant to read. Humans expect 1 line value to change to correspond to 1 row changing:

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 any additional deleted lines are present, they stay deleted. We do not do anything to make people feel bad. That maintains the readability of large object insertions, without the assumption that each added line replaced an old one.

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 Int32Array is 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_000 matrix 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.

This is not a purity move in theory. That is a product decision. A slightly less elegant diff is better than a frozen browser tab for someone pasting a massive fixture.

If you have very large diffs at the repository scale, use specialized file tools or a streaming diff approach. Usually the right balance is the parse-normalise-LCS pipeline for pasted API responses, configs, fixtures and webhook payloads.

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 defined by 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.

JSON Patch vs JSON Merge Patch

HTTP PATCH names the method, not the body format. The request should declare which patch document it carries.

JSON Merge Patch (application/merge-patch+json) looks like a partial resource:

{
  "displayName": "Ada L.",
  "deprecatedField": null
}

Object members are recursively merged, but null deletes an object member and arrays are replaced as complete values. It is concise for ordinary profile or settings updates, but it cannot distinguish “store a real null” from “delete this member.”

JSON Patch (application/json-patch+json) is an ordered operation list:

[
  { "op": "test", "path": "/version", "value": 7 },
  { "op": "replace", "path": "/displayName", "value": "Ada L." },
  { "op": "add", "path": "/roles/-", "value": "editor" },
  { "op": "remove", "path": "/deprecatedField" }
]

It supports add, remove, replace, move, copy, and test, precise array positions, and real null values. The extra precision also creates more validation work: every path, operation, value type, authorization rule, array index, and size limit needs checking.

Requirement Better default
A few ordinary object fields JSON Merge Patch
Delete means sending null JSON Merge Patch
Store an actual null JSON Patch
Insert or remove one array item JSON Patch
Conditional update inside the document JSON Patch with test
Human-readable partial object JSON Merge Patch

Patch requests still need concurrency control. Use an ETag with If-Match or an equivalent version check so a valid patch does not overwrite a newer resource. A JSON Patch test can express a document precondition, but it does not replace the server's authorization and transaction boundary.

Deriving a patch from a diff is convenient, not automatically safe. Review whether array changes are positional, whether a missing key differs from null, and whether the target API permits the requested fields before applying it.

Edge Cases Worth Knowing

Invalid JSON

JSON diff can not do anything interesting until both sides parse. Repair first, compare later. Comparing raw broken text may mask 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

might round during parsing. If you want to preserve the exact digits, store IDs, ledger values or snowflake-style identifiers as strings when you compare them.

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?

JSON objects are not affected by the order of their keys, nor by whitespace. An equivalent object that is reordered can be marked as changed by a text diff. 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, as JSON arrays are ordered. If your arrays contain unordered records with stable IDs, sort the 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.

What is the difference between JSON Patch and JSON Merge Patch?

JSON Merge Patch sends a partial object and uses null to delete a member; arrays are replaced whole. JSON Patch sends ordered operations with JSON Pointer paths, so it can target array items, preserve real null, and express tests, moves, and copies.

How do I compare JSON with jq?

Normalize both files with jq -S ., then run a normal diff on the outputs. Add domain-specific array sorting only when array order is known to be irrelevant.

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.

Sources

Last reviewed August 2026.