← All Articles
JSON

JSON Comparator Explained with Examples — How JSON Diffing Works

Comparing two JSON objects sounds trivial — just check if they are equal. In practice, JSON comparison is nuanced enough to have entire libraries dedicated to it. Should key order matter? Are 1 and 1.0 the same? Is an array ["a","b"] equal to ["b","a"]? What is the clearest way to show a reviewer exactly what changed between two large nested JSON responses? This guide explains how JSON comparison works under the hood, covers the different comparison strategies you'll need in different situations, and shows real-world examples of each.

The four comparison questions you must answer first

Before diffing two JSON documents, you need to decide how your comparison should behave across four dimensions. Getting these wrong produces false positives or hides real differences.

Question 1: Does key ordering matter?
  { "a": 1, "b": 2 }  vs  { "b": 2, "a": 1 }
  → Per JSON spec (RFC 8259): NO — objects are unordered maps.
  → In practice: most comparators treat these as equal (correct).
  → Some string-diff tools treat them as different (wrong for JSON).

Question 2: Does array ordering matter?
  ["red", "blue"]  vs  ["blue", "red"]
  → JSON arrays ARE ordered — different order = different value.
  → But for things like permission lists, order is semantically irrelevant.
  → Decision: structural diff = order matters. Semantic diff = configurable.

Question 3: Are numeric types compared strictly?
  { "count": 1 }   vs  { "count": 1.0 }
  → Structurally different (integer vs float)
  → Numerically equal
  -> JavaScript drops it entirely: JSON.stringify(JSON.parse("15000.00")) is "15000".
  -> Java keeps a decimal point but not the scale: Jackson round-trips 15000.00
     as 15000.0. Preserving 15000.00 needs BigDecimal (a BigDecimal field, or
     USE_BIG_DECIMAL_FOR_FLOATS with trailing-zero stripping turned off).
  → Decision depends on your use case.

Question 4: How do you handle null vs missing key?
  { "name": "Priya", "city": null }
  vs
  { "name": "Priya" }
  → Structurally different: one has "city" key, one does not.
  → Semantically, both represent "city is not known."
  → API versioning often treats these differently.

Structural diff vs semantic diff

Structural diff (strict)

A structural diff treats the JSON as a tree and reports every node that differs, including type differences, null vs absent, and array ordering. This is what you want for exact contract testing — verifying that an API response matches a reference response byte-for-byte in structure and values.

// Left JSON
{
  "order": {
    "id": "ORD-001",
    "items": ["phone", "charger"],
    "total": 15000,
    "discount": null
  }
}

// Right JSON
{
  "order": {
    "id": "ORD-001",
    "items": ["charger", "phone"],
    "total": 15000.00,
    "gst": 2700
  }
}

// Structural diff result, as the JavaScript differ below produces it:
// CHANGED  order.items[0]: "phone" -> "charger"
// CHANGED  order.items[1]: "charger" -> "phone"
// REMOVED  order.discount
// ADDED    order.gst: 2700
//
// Note what is NOT in that list: order.total 15000 vs 15000.00.
// JSON.parse gives JavaScript one number type, so a JS differ physically
// cannot see the difference - see Question 3 above. A typed differ
// (Java/Jackson, which parses these as IntNode and DoubleNode) does report
// it as a type change. Which behaviour you get is a property of the
// language your comparator is written in, not of the JSON.

Semantic diff (lenient)

A semantic diff ignores differences that don't change meaning: key ordering, numeric type (int vs float for equal values), and optionally array ordering. This is what you want for API migration testing — verifying that a refactored API returns the same data even if the serialisation changed.

// Same JSONs as above — semantic diff result:
// CHANGED  order.items: order differs ["phone","charger"] vs ["charger","phone"]
//   (if array-order-insensitive mode: NO DIFFERENCE reported)
// REMOVED  order.discount
// ADDED    order.gst: 2700
//
// order.total: 15000 vs 15000.00 -> NO DIFFERENCE (numerically equal;
//   a typed differ would report a type change here, a JS one sees nothing)

How nested object diffing works

A JSON diff algorithm traverses both trees simultaneously, recursing into matching keys. The challenge is deciding what to do when a key exists in one document but not the other — the algorithm must mark it as added or removed without recursing further.

// Pseudocode: recursive JSON diff

// typeof is not enough on its own: typeof null and typeof [] are both
// "object", so a naive type guard reports null-vs-object as a value change
// and cannot tell [1,2] from {"0":1,"1":2}.
function typeOf(value) {
  if (value === null) return "null";
  if (Array.isArray(value)) return "array";
  return typeof value;
}

function diff(left, right, path = "") {
  const leftType = typeOf(left);
  const rightType = typeOf(right);

  if (leftType !== rightType) {
    return [{ path, type: "TYPE_CHANGED", from: leftType, to: rightType }];
  }

  if (leftType !== "object" && leftType !== "array") {
    // Primitive (or both null) - direct comparison
    if (left !== right) return [{ path, type: "VALUE_CHANGED", from: left, to: right }];
    return [];
  }

  const diffs = [];
  // "key in left" walks the prototype chain, so a key named constructor,
  // toString or valueOf present only on the right would be reported as a
  // type change from "function" instead of ADDED. Check own properties.
  const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
  const allKeys = new Set([...Object.keys(left), ...Object.keys(right)]);

  for (const key of allKeys) {
    const childPath = leftType === "array"
      ? `${path}[${key}]`
      : (path ? `${path}.${key}` : key);

    if (!has(left, key)) {
      diffs.push({ path: childPath, type: "ADDED", value: right[key] });
    } else if (!has(right, key)) {
      diffs.push({ path: childPath, type: "REMOVED", value: left[key] });
    } else {
      diffs.push(...diff(left[key], right[key], childPath));
    }
  }
  return diffs;
}

// One limitation this cannot fix: integers beyond 2^53 are already lost by
// JSON.parse before the differ sees them, so 9007199254740993 and
// 9007199254740992 compare equal. RFC 8259 section 6 puts exact
// interoperability in the range -(2^53)+1 to (2^53)-1. Order IDs and payment
// IDs routinely exceed that - compare them as strings, or use a parser that
// preserves them.

Array comparison strategies

Arrays are the hardest part of JSON diffing because unlike objects (where keys identify elements), arrays use position to identify elements. Three strategies exist:

Strategy 1: Position-based (default)

// Left:  ["apple", "banana", "mango"]
// Right: ["apple", "mango"]
//
// Position-based diff:
// index[1]: "banana" → "mango"  (CHANGED)
// index[2]: "mango"  → REMOVED
//
// This is technically correct but not intuitive.
// It looks like mango moved, not like banana was deleted.

Strategy 2: LCS-based (longest common subsequence)

// Same input — LCS diff:
// REMOVED: "banana" at index 1
// (apple and mango are in common subsequence — only banana was removed)
//
// More intuitive for human reading.
// Used by git diff for text files — adapted for JSON arrays.

Strategy 3: Key-based (for arrays of objects)

// Left array of order items:
[
  { "sku": "PHONE-01", "qty": 2, "price": 12000 },
  { "sku": "CASE-05",  "qty": 1, "price": 500 }
]

// Right array:
[
  { "sku": "CASE-05",  "qty": 2, "price": 450 },  // qty changed, price changed
  { "sku": "PHONE-01", "qty": 2, "price": 12000 }
]

// Position-based diff: sees 2 items completely changed (wrong — they just reordered)
// Key-based diff (key = "sku"):
//   PHONE-01: no change
//   CASE-05:  qty 1 → 2, price 500 → 450
//
// Key-based is far more useful for object arrays.
// Specify the key field in your comparator to enable this.

Real-world use cases with examples

1. API contract regression testing

You refactored a Spring Boot controller. You want to verify the response JSON didn't change. Capture the reference response, run after refactor, diff:

// JUnit test using JSONassert library
@Test
void orderResponseShouldMatchReference() throws Exception {
    String actual = mockMvc.perform(get("/api/orders/ORD-001"))
        .andReturn()
        .getResponse()
        .getContentAsString();

    String expected = Files.readString(Path.of("src/test/resources/order_reference.json"));

    // STRICT is "not extensible, and strict array ordering": no extra fields
    // allowed in the actual response, and arrays must be in the same order.
    // (Key order is insensitive in every mode, so that is not what STRICT adds.)
    JSONAssert.assertEquals(expected, actual, JSONCompareMode.STRICT);
}

@Test
void orderResponseShouldContainAtLeastTheReferenceFields() throws Exception {
    // Separate test - asserting LENIENT after STRICT in the same body proves
    // nothing: if STRICT passed, LENIENT is trivially true; if STRICT failed,
    // this line never runs.
    // LENIENT: extra fields in the actual response are allowed, array order
    // is not enforced.
    JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT);
}

2. Debugging webhook payload changes

Suppose your handler was written against a flat payload and the provider sends an enveloped one. The concrete case below is Razorpay's payment.captured webhook, reproduced from their public documentation. The flat "before" is illustrative — a shape a hand-rolled integration might have stored — not a claim about Razorpay's own history; the "after" is the real envelope. Comparing them shows exactly what was added, removed, or moved:

// What your handler was written against (illustrative flat shape)
{
  "event": "payment.captured",
  "payment_id": "pay_29QQoUBi66xm2f",
  "order_id": "order_9A33XWu170gUtm",
  "amount": 50000,
  "currency": "INR",
  "status": "captured"
}

// What Razorpay actually sends: payment.captured
// Note "entity": "event" at the top level. The value "payment" belongs to the
// NESTED payload.payment.entity.entity - getting those two the wrong way round
// is an easy mistake to make and an easy one for a reviewer to catch.
{
  "entity": "event",
  "account_id": "acc_BFQ7uQEaa7j2z7",
  "event": "payment.captured",
  "contains": ["payment"],
  "payload": {
    "payment": {
      "entity": {
        "id": "pay_29QQoUBi66xm2f",
        "entity": "payment",
        "amount": 50000,
        "currency": "INR",
        "status": "captured",
        "order_id": "order_9A33XWu170gUtm",
        "method": "upi",
        "captured": true
      }
    }
  },
  "created_at": 1567692556
}

// Diff reveals:
// REMOVED: payment_id (moved to payload.payment.entity.id)
// REMOVED: order_id   (moved to payload.payment.entity.order_id)
// ADDED:   entity, account_id, contains, created_at
// ADDED:   payload (entire nested structure)
// -> Breaking change - your webhook handler must be updated

3. Config file drift detection

Comparing a application.json across environments (dev vs prod) to find configuration that drifted without going through the normal change process:

// Diff result from dev vs prod config:
// CHANGED  database.pool.maxSize: 5 (dev) → 50 (prod)      ← expected
// CHANGED  cache.ttl: 60 (dev) → 3600 (prod)               ← expected
// CHANGED  feature.newCheckout: true (dev) → false (prod)  ← forgotten feature flag!
// ADDED    logging.level: "ERROR" in prod                   ← silently added in prod
// → The logging.level difference is a bug — was added directly in prod, bypassing PR review

JSONPath — targeting specific parts of a large JSON

When comparing large JSON documents (API responses with 100+ fields), you often only care about specific sections. JSONPath lets you extract nested values using a path expression:

// JSONPath syntax (similar to XPath for XML)
$.store.book[0].title           // first book title
$.store.book[*].author          // all authors
$.store.book[?(@.price < 10)]   // books cheaper than 10
$..price                        // all price fields anywhere in the document

// Java — using Jayway JsonPath library
import com.jayway.jsonpath.JsonPath;

String json = /* large API response */;
List<String> authors = JsonPath.read(json, "$.store.book[*].author");

// A filter makes the path INDEFINITE, and an indefinite path always returns a
// list - reading it into a Double throws ClassCastException. The trailing [0]
// does not index the result set either; index the returned list yourself.
List<Double> cheapPrices = JsonPath.read(json, "$.store.book[?(@.price < 10)].price");
Double firstCheapPrice = cheapPrices.isEmpty() ? null : cheapPrices.get(0);

Frequently Asked Questions

Why does JSON key order sometimes matter in practice even though the spec says it shouldn't?

The JSON spec says objects are unordered, but certain tools and systems create implicit dependencies on key order. Java's LinkedHashMap preserves insertion order, so a serialised object always produces the same key order — developers sometimes rely on this for readability in logs. Some poorly written API parsers use string matching rather than proper JSON parsing, making them sensitive to key order. Some signature schemes need a canonical form: RFC 8785, the JSON Canonicalization Scheme, exists for exactly this, and webhook verification that re-serialises the body before checking the signature depends on reproducing the original byte-for-byte. JWS is the counter-example rather than an example — it signs the exact base64url-encoded octets that are transmitted (RFC 7515 section 2 defines the signing input as the encoded header and payload) precisely so that no canonicalisation is required. The rule: your code must never depend on key order, but you may encounter systems that do.

What is JSON Merge Patch and how does it differ from JSON Patch?

JSON Merge Patch (RFC 7396) is a simple format for describing changes to a JSON document — you send only the changed keys, and null means delete. It's human-readable but cannot express array operations. JSON Patch (RFC 6902) is a more powerful format using an array of operations (add, remove, replace, move, copy, test) that can precisely describe any change including array element insertion. PATCH API endpoints commonly use JSON Merge Patch for simple updates. JSON Patch is used when you need precise atomic operations, such as optimistic concurrency control.

How do I compare two JSON arrays where order doesn't matter?

For primitive arrays, sort both before comparing:Arrays.sort(arr1); Arrays.sort(arr2); then compare. For object arrays, define a canonical sort key (e.g., sort by id field) and sort both arrays by that key before diffing. On JSONassert, note that JSONCompareMode.NON_EXTENSIBLE is not the "unordered arrays" mode — its purpose is rejecting extra fields in the actual response, and it happens to leave array ordering non-strict, which LENIENT does too. For sets of complex objects, consider converting to a Map keyed by identifier before diffing — this is the "key-based" strategy described above.

Can JSON comparison be used for database change tracking?

Yes, and it's a powerful pattern. Store the JSON representation of an entity as a column (Postgres JSONB works well). On every update, compute the JSON diff between old and new values and store the diff in an audit log table. This gives you a human-readable record of exactly what changed on every row, without needing separate audit log columns for every field. The diff storage is compact — only changed fields are stored — and queryable with JSONPath operators in Postgres.

Try it in the browser