DEV Community

Deepak Kumar
Deepak Kumar

Posted on

Why 'diff' fails on JSON (and how to compare two JSON files properly)

You have two API responses that should be identical. You run diff a.json b.json and get 400 lines of red and green. Then you notice the data is the same: one file just lists the keys in a different order.

Text diff compares characters. JSON is a data format, and two files can mean exactly the same thing while looking completely different. This post covers why, and three ways to compare JSON by meaning instead of by text.

What counts as "the same" JSON?

RFC 8259 defines a JSON object as an unordered collection of name/value pairs, and an array as an ordered sequence of values. That gives three rules:

  • {"a":1,"b":2} and {"b":2,"a":1} are the same object.
  • [1,2] and [2,1] are different arrays.
  • Whitespace between tokens never matters.

A text diff gets all three wrong. It flags reordered keys and reformatted whitespace as changes, which is noise, and it can't tell you that a change inside a nested object happened at $.address.city.

Option 1: sort keys, then diff (jq)

The quickest fix on the command line is to normalise both files first. jq -S sorts object keys recursively and pretty-prints:

diff <(jq -S . a.json) <(jq -S . b.json)
Enter fullscreen mode Exit fullscreen mode

This removes key-order and whitespace noise, and a normal diff of the result is readable. Two limits:

  • Array order is still significant, which is usually correct.
  • You still get a line diff, not a list of paths that changed.

Option 2: Python, where equality already works

If you only need to know whether two documents are equal, Python's json module already compares by meaning:

import json

a = json.loads('{"a":1,"b":[1,2]}')
b = json.loads('{"b":[1,2],"a":1.0}')

print(a == b)                                   # True: key order ignored, 1 == 1.0
print(json.loads('[1,2]') == json.loads('[2,1]'))  # False: array order matters
Enter fullscreen mode Exit fullscreen mode

For a readable list of differences, the DeepDiff library is a well-known option.

Option 3: a small path-based diff in JavaScript

When you want to know what changed and where, a recursive function is about 30 lines. This one is tested and returns a path for each difference:

function typeOf(v) {
  if (v === null) return "null";
  if (Array.isArray(v)) return "array";
  return typeof v;
}

function diff(a, b, path = "$") {
  if (a === b) return [];
  const ta = typeOf(a), tb = typeOf(b);
  if (ta !== tb) return [{ path, kind: "type", from: a, to: b }];

  if (ta === "array") {
    const out = [];
    const len = Math.max(a.length, b.length);
    for (let i = 0; i < len; i++) {
      const p = `${path}[${i}]`;
      if (i >= a.length) out.push({ path: p, kind: "added", to: b[i] });
      else if (i >= b.length) out.push({ path: p, kind: "removed", from: a[i] });
      else out.push(...diff(a[i], b[i], p));
    }
    return out;
  }

  if (ta === "object") {
    const out = [];
    const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
    for (const k of keys) {
      const p = `${path}.${k}`;
      if (!Object.hasOwn(a, k)) out.push({ path: p, kind: "added", to: b[k] });
      else if (!Object.hasOwn(b, k)) out.push({ path: p, kind: "removed", from: a[k] });
      else out.push(...diff(a[k], b[k], p));
    }
    return out;
  }

  return [{ path, kind: "changed", from: a, to: b }];
}
Enter fullscreen mode Exit fullscreen mode

Running it on two objects that differ in key order, one array item, one nested value and one new key:

const a = JSON.parse('{"name":"Ada","tags":["x","y"],"address":{"city":"London","zip":"N1"}}');
const b = JSON.parse('{"address":{"zip":"N1","city":"Paris"},"tags":["x","z"],"name":"Ada","admin":true}');
console.log(diff(a, b));
Enter fullscreen mode Exit fullscreen mode

gives exactly three differences, and the reordered keys are not among them:

$.tags[1]       changed  "y" -> "z"
$.address.city  changed  "London" -> "Paris"
$.admin         added    true
Enter fullscreen mode Exit fullscreen mode

(Object.hasOwn needs a recent runtime. It is available in current Node and all modern browsers.)

The array problem

Arrays are where most "false differences" come from. Suppose an API returns users in a different order on each call:

const before = [{ id: 1, n: "a" }, { id: 2, n: "b" }];
const after  = [{ id: 2, n: "b" }, { id: 1, n: "c" }];
Enter fullscreen mode Exit fullscreen mode

Compared by position, that reports four differences, because every item shifted. Only one thing really changed: user 1's n went from "a" to "c". The fix is to index the array by a stable key before comparing:

const byId = (list, key = "id") =>
  Object.fromEntries(list.map((item) => [item[key], item]));

diff(byId(before), byId(after));
// -> one difference, at the entry for id 1, field n
Enter fullscreen mode Exit fullscreen mode

This only works if the items have a unique identifier. For arrays of plain values (["x","y"]), the honest options are to sort both sides first or to accept that order is part of the data.

Gotchas that bite in production

  • Large numbers. JavaScript parses every JSON number as a 64-bit float. JSON.parse("9007199254740993") returns 9007199254740992, so two different IDs can compare as equal. If your data has 64-bit IDs, compare them as strings or use a BigInt-aware parser.
  • Duplicate keys. RFC 8259 says names within an object should be unique, but parsers accept duplicates. JSON.parse('{"a":1,"a":2}') quietly keeps the last value, so one side of a comparison can lose data before your code ever sees it.
  • 1 versus 1.0. JavaScript and Python treat them as equal after parsing. A text diff doesn't, and a strict schema might not either.
  • Type changes. "5" (string) and 5 (number) are different values, and this is a common cause of real API regressions. The diff function above reports it as a type difference.

When you just want to look at a diff

For one-off checks I got tired of writing scripts, so I built a free compare JSON online page. Disclosure: I'm the author. You paste two documents, it ignores key order and whitespace, and it lists what's different. It runs in your browser and doesn't upload what you paste, which matters when the JSON is an API response with real data in it.

For anything automated, such as tests or CI, use one of the code options above, since they run in your pipeline and can be asserted on.

Summary

  • Text diff is the wrong tool because JSON object order and whitespace carry no meaning.
  • jq -S plus diff is the fastest command-line fix.
  • Python's == already compares by meaning; DeepDiff gives readable output.
  • A 30-line recursive function gives path-based differences in JavaScript.
  • Match arrays by a stable id when order isn't meaningful, and watch out for big numbers and duplicate keys.

What do you use to compare JSON in your workflow? I'd like to hear about edge cases I haven't covered.

Top comments (0)