Skip to content
DevPenh

Deep Compare Objects in JavaScript and List the Differences

Why === and JSON.stringify fail, when to use isDeepStrictEqual, assert or lodash isEqual, and a tested 30-line function that lists JSON differences by path.

By Yorn Chanpenh · Last reviewed

JavaScript has no built-in deepEqual operator, so comparing two objects or two parsed JSON responses takes a deliberate choice. This guide shows why the quick tricks fail, which built-in and library functions answer "are these equal?", and a short function that answers the more useful question: "what changed?"

Every output below was produced on Node.js 24.21.0, with Vitest 3.2.4 and lodash 4.17.21 where they are used.

Why === and JSON.stringify do not work

=== compares object identity, not content, so two separate objects are never equal. Comparing JSON.stringify output looks like a fix, but it depends on key order:

const a = { id: 1, name: "Ada" };
const b = { name: "Ada", id: 1 };

a === b;                                   // false
JSON.stringify(a) === JSON.stringify(b);   // false: same data, different key order

It also loses information. undefined properties disappear, NaN becomes null and dates become strings:

JSON.stringify({ x: undefined, y: NaN, d: new Date(0) });
// '{"y":null,"d":"1970-01-01T00:00:00.000Z"}'

So two different values can produce the same string, and two equal values can produce different strings.

Built-in deep equality in Node.js

Node ships a correct deep comparison in node:util:

import { isDeepStrictEqual } from "node:util";

isDeepStrictEqual({ id: 1, name: "Ada" }, { name: "Ada", id: 1 }); // true
isDeepStrictEqual({ n: 1 }, { n: "1" });                         // false
isDeepStrictEqual({ a: 1, b: undefined }, { a: 1 });             // false
isDeepStrictEqual([1, 2], [2, 1]);                               // false
isDeepStrictEqual({ v: NaN }, { v: NaN });                       // true

Key order does not matter, types do, an explicit undefined property counts as a difference, and array order matters. That is usually what you want for JSON. Avoid the non-strict assert.deepEqual: it uses loose equality, so { n: 1 } and { n: "1" } pass.

When you want to see the difference rather than a boolean, assert.deepStrictEqual throws with a readable diff:

import assert from "node:assert";

assert.deepStrictEqual(
  { id: 1, user: { name: "Ada", roles: ["admin"] } },
  { id: 1, user: { name: "Ada", roles: ["viewer"] } },
);
// AssertionError: Expected values to be strictly deep-equal:
// + actual - expected
//
//   {
//     id: 1,
//     user: {
//       name: 'Ada',
//       roles: [
// +       'admin'
// -       'viewer'
//       ]
//     }
//   }

In the browser: lodash isEqual

Browsers have no equivalent of isDeepStrictEqual. The most common choice is isEqual from lodash (MIT licence), which gives the same answers for the cases above:

import { isEqual } from "lodash";

isEqual({ id: 1, name: "Ada" }, { name: "Ada", id: 1 }); // true
isEqual({ n: 1 }, { n: "1" });                         // false
isEqual({ a: 1, b: undefined }, { a: 1 });             // false
isEqual([1, 2], [2, 1]);                               // false

In tests: toEqual vs toStrictEqual

Vitest and Jest print a structural diff when an object assertion fails. The two matchers differ in one way that often surprises people:

expect({ a: 1, b: undefined }).toEqual({ a: 1 });       // passes
expect({ a: 1, b: undefined }).toStrictEqual({ a: 1 }); // fails

expect({ id: 1, plan: { seats: 5 } }).toEqual({ id: 1, plan: { seats: "5" } });
// - Expected
// + Received
// -     "seats": "5",
// +     "seats": 5,

toEqual ignores properties whose value is undefined; toStrictEqual does not. Use toStrictEqual when a property being present matters, for example in an API contract test.

List the differences by path

Equality functions answer yes or no. To review a change, such as a staging response against production, you want a list: which path was added, removed or changed, with the old and new values. For parsed JSON (objects, arrays, strings, numbers, booleans and null), a recursive function is enough:

function isObject(value) {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function jsonDiff(before, after, path = "$", changes = []) {
  if (Object.is(before, after)) return changes;

  if (Array.isArray(before) && Array.isArray(after)) {
    const length = Math.max(before.length, after.length);
    for (let i = 0; i < length; i++) {
      const p = `${path}[${i}]`;
      if (i >= before.length) changes.push({ op: "added", path: p, after: after[i] });
      else if (i >= after.length) changes.push({ op: "removed", path: p, before: before[i] });
      else jsonDiff(before[i], after[i], p, changes);
    }
    return changes;
  }

  if (isObject(before) && isObject(after)) {
    const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
    for (const key of keys) {
      const p = `${path}.${key}`;
      if (!Object.hasOwn(after, key)) changes.push({ op: "removed", path: p, before: before[key] });
      else if (!Object.hasOwn(before, key)) changes.push({ op: "added", path: p, after: after[key] });
      else jsonDiff(before[key], after[key], p, changes);
    }
    return changes;
  }

  changes.push({ op: "changed", path, before, after });
  return changes;
}

Run it on two versions of an API response:

const staging = {
  id: 42,
  status: "active",
  plan: { name: "pro", seats: 5 },
  tags: ["beta", "eu"],
  trialEndsAt: "2026-12-01",
};
const production = {
  id: 42,
  status: "active",
  plan: { name: "pro", seats: "5" },
  tags: ["beta"],
  region: "eu-west-1",
};

jsonDiff(staging, production);
// [
//   { op: 'changed', path: '$.plan.seats', before: 5, after: '5' },
//   { op: 'removed', path: '$.tags[1]', before: 'eu' },
//   { op: 'removed', path: '$.trialEndsAt', before: '2026-12-01' },
//   { op: 'added', path: '$.region', after: 'eu-west-1' }
// ]

The number that became a string is reported as a change, which a text diff of pretty-printed JSON would show only as a pair of quotes.

Why Object.hasOwn and not the in operator

The first version of this function used key in after. That also finds inherited properties, so a JSON key named toString that was removed was reported as changed to [Function: toString] instead of removed. Object.hasOwn checks only the object's own keys and reports { op: 'removed', path: '$.toString', before: 'x' } as expected.

Limits of this function

  • It is for JSON data. Dates, maps, sets and class instances need extra cases.
  • Paths use dot notation, so a key that contains a dot or a space makes the path ambiguous. Use bracket notation such as $["a.b"] if your keys can contain them.
  • Arrays are compared by position, covered next.

Arrays where order does not matter

Comparing by position works for ordered lists, but a list of records that only changed order produces noise. Two items swapped, one quantity changed:

const v1 = [{ id: "a", qty: 1 }, { id: "b", qty: 2 }];
const v2 = [{ id: "b", qty: 3 }, { id: "a", qty: 1 }];

jsonDiff(v1, v2);
// [
//   { op: 'changed', path: '$[0].id', before: 'a', after: 'b' },
//   { op: 'changed', path: '$[0].qty', before: 1, after: 3 },
//   { op: 'changed', path: '$[1].id', before: 'b', after: 'a' },
//   { op: 'changed', path: '$[1].qty', before: 2, after: 1 }
// ]

Index the items by a stable key first, and only the real change is left:

function indexBy(items, key) {
  return Object.fromEntries(items.map((item) => [item[key], item]));
}

jsonDiff(indexBy(v1, "id"), indexBy(v2, "id"));
// [ { op: 'changed', path: '$.b.qty', before: 2, after: 3 } ]

Without writing code

The JSON diff tool does the same path-by-path comparison in your browser, with options to ignore array order or match array items by a key such as id. Nothing you paste is uploaded. If one side does not parse, the JSON formatter shows the line and column of the error. For the reasoning behind each rule, see how to compare two JSON files, and for a full workflow with real responses, compare staging and production API responses.

Part of the API debugging series. Start with How to Debug API Responses: A Practical Workflow.