Learn how to compare two JSON objects, understand structural diffs, detect schema changes, and safely merge JSON data in your applications.
As a developer, you compare JSON constantly — checking API responses between environments, reviewing configuration file changes, or detecting data drift between a cached version and fresh data. Yet JSON comparison is deceptively tricky: two JSON objects with identical data can have different key ordering, whitespace, and array order, making naive string comparison useless.
Consider these two semantically identical JSON objects:
// Object A
{ "name": "Alice", "age": 30, "active": true }
// Object B
{ "active": true, "name": "Alice", "age": 30 }
JSON.stringify(a) === JSON.stringify(b) returns false even though the data is identical, because key ordering differs. Structural comparison is always the right approach.
A proper JSON diff tool categorizes changes into four types:
| Change Type | Description | Example |
|---|---|---|
| Added | Key exists in B but not A | "email": "alice@example.com" added |
| Removed | Key exists in A but not B | "phone" field deleted |
| Modified | Key exists in both but value changed | "age": 30 → 31 |
| Unchanged | Key and value identical in both | "name": "Alice" |
Shallow comparison only checks top-level keys. Deep comparison recursively traverses nested objects and arrays — the correct approach for complex JSON.
// Shallow compare (misses nested changes)
function shallowDiff(a, b) {
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
const diff = {}
for (const key of keys) {
if (a[key] !== b[key]) diff[key] = { from: a[key], to: b[key] }
}
return diff
}
// Deep compare using JSON.stringify (handles nesting, not order-stable for arrays)
function deepEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b)
}
function deepDiff(before, after, path = "") {
const changes = []
if (typeof before !== typeof after) {
return [{ path, type: "modified", from: before, to: after }]
}
if (typeof before !== "object" || before === null) {
if (before !== after) {
changes.push({ path, type: "modified", from: before, to: after })
}
return changes
}
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
for (const key of allKeys) {
const childPath = path ? `${path}.${key}` : key
if (!(key in before)) {
changes.push({ path: childPath, type: "added", value: after[key] })
} else if (!(key in after)) {
changes.push({ path: childPath, type: "removed", value: before[key] })
} else {
changes.push(...deepDiff(before[key], after[key], childPath))
}
}
return changes
}
// Usage
const before = { user: { name: "Alice", role: "admin" }, version: 1 }
const after = { user: { name: "Alice", role: "editor" }, version: 2, active: true }
console.log(deepDiff(before, after))
// [
// { path: "user.role", type: "modified", from: "admin", to: "editor" },
// { path: "version", type: "modified", from: 1, to: 2 },
// { path: "active", type: "added", value: true }
// ]
Arrays are the trickiest part of JSON diffing. There are two approaches:
Index-based diff — compare a[0] with b[0], a[1] with b[1], etc. Fast but fragile: inserting an element at the start makes everything look "changed".
LCS-based diff (like diff) — use the longest common subsequence algorithm to find minimal edits. This is what Git, RFC 6902 (JSON Patch), and libraries like jsondiffpatch use.
// Libraries that handle array diffing correctly
import { diff } from "jsondiffpatch"
import jiff from "jiff" // RFC 6902 JSON Patch
const delta = diff(before, after)
RFC 6902 defines a standard format for expressing JSON changes as a sequence of operations. It is the foundation for optimistic UI updates and HTTP PATCH requests.
[
{ "op": "replace", "path": "/user/role", "value": "editor" },
{ "op": "replace", "path": "/version", "value": 2 },
{ "op": "add", "path": "/active", "value": true }
]
Operations: add, remove, replace, move, copy, test.
test("user API response matches schema", async () => {
const response = await fetchUser(1)
const changes = deepDiff(expectedShape, response)
const unexpectedChanges = changes.filter(c => c.type !== "unchanged")
expect(unexpectedChanges).toHaveLength(0)
})
Before deploying a config change, run a diff to visualize exactly what will change — catching "accidental" modifications before they hit production.
async function updateUser(id, newData) {
const before = await getUser(id)
await db.users.update(id, newData)
const after = await getUser(id)
await auditLog.write({ entity: "user", id, diff: deepDiff(before, after) })
}
Paste two JSON objects side-by-side into HeoLab's JSON Diff tool to get an instant, color-coded diff with added (green), removed (red), and modified (yellow) lines highlighted — no installation required.
String comparison is never reliable for JSON. Always use structural comparison that handles key ordering, nested objects, and array changes. For production code, reach for RFC 6902 JSON Patch to express changes as a portable, reversible list of operations.