Diff JSON Objects
How to compare two JSON objects and find differences in Python, Java, and JavaScript.
Overview
Comparing two JSON objects sounds trivial until you try it on nested data. {"a": {"b": 1}} and {"a": {"b": 2}} aren’t equal, but == only tells you that they differ — not where, or how. A real JSON diff walks both trees and reports which keys were added, removed, or changed, at any depth. That’s what you need for snapshot tests, config drift checks, API contract monitoring, and migration audits.
This recipe shows three flavors across Python, JavaScript, and Java: a categorized report (added / removed / updated), a reversible RFC 6902 patch, and a dependency-free recursive diff.
When to Use
Use this recipe when:
- Validating that a REST API response matches an expected snapshot
- Detecting configuration drift between environment files (staging vs production)
- Auditing a migration by diffing before/after data exports
- Building an
applyPatchflow that replays changes between systems
If you only need to merge two objects, see Merge JSON Files . Diffing and merging are related but opposite jobs.
Solution
Python
deepdiff gives a categorized report out of the box:
# pip install deepdiff
from deepdiff import DeepDiff
old = {"user": {"name": "Alice", "age": 30}, "roles": ["admin"]}
new = {"user": {"name": "Alice", "age": 31}, "roles": ["admin", "editor"]}
diff = DeepDiff(old, new)
print(diff)
# {'values_changed': {"root['user']['age']": {'old_value': 30, 'new_value': 31}},
# 'iterable_item_added': {"root['roles'][1]": 'editor'}}
With no third-party dependencies, a short recursive walk does the same job and is easier to customize:
def json_diff(old, new, path=""):
"""Return {path: change} for two JSON-like structures."""
changes = {}
if old == new:
return changes
if isinstance(old, dict) and isinstance(new, dict):
for key in old.keys() | new.keys():
if key not in old:
changes[f"{path}/{key}"] = {"added": new[key]}
elif key not in new:
changes[f"{path}/{key}"] = {"removed": old[key]}
else:
changes.update(json_diff(old[key], new[key], f"{path}/{key}"))
elif isinstance(old, list) and isinstance(new, list):
for i, (o, n) in enumerate(zip(old, new)):
changes.update(json_diff(o, n, f"{path}/{i}"))
for i in range(len(old), len(new)):
changes[f"{path}/{i}"] = {"added": new[i]}
for i in range(len(new), len(old)):
changes[f"{path}/{i}"] = {"removed": old[i]}
else:
changes[path or "/"] = {"old": old, "new": new}
return changes
JavaScript
fast-json-patch produces an RFC 6902 patch you can replay with applyPatch:
// npm install fast-json-patch
import * as jsonpatch from 'fast-json-patch';
const oldDoc = { user: { name: 'Alice', age: 30 }, roles: ['admin'] };
const newDoc = { user: { name: 'Alice', age: 31 }, roles: ['admin', 'editor'] };
const patch = jsonpatch.compare(oldDoc, newDoc);
console.log(patch);
// [
// { op: 'replace', path: '/user/age', value: 31 },
// { op: 'add', path: '/roles/1', value: 'editor' }
// ]
For a simple added/changed/deleted report, deep-object-diff is lighter:
// npm install deep-object-diff
import { detailedDiff } from 'deep-object-diff';
console.log(detailedDiff(oldDoc, newDoc));
// {
// added: { roles: { '1': 'editor' } },
// deleted: {},
// updated: { user: { age: 31 } }
// }
Java
zjsonpatch emits the same RFC 6902 format through Jackson:
// Maven: com.flipkart.zjsonpatch:zjsonpatch
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.flipkart.zjsonpatch.JsonDiff;
public class JsonDiffExample {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
JsonNode oldNode = mapper.readTree("{\"user\":{\"name\":\"Alice\",\"age\":30}}");
JsonNode newNode = mapper.readTree("{\"user\":{\"name\":\"Alice\",\"age\":31}}");
JsonNode patch = JsonDiff.asJson(oldNode, newNode);
System.out.println(patch.toPrettyString());
// [{"op":"replace","path":"/user/age","value":31}]
}
}
If you want a plain report without the dependency, recurse over the Jackson tree — a shallow equals on the root only tells you the trees differ:
public static Map<String, Object[]> diff(JsonNode a, JsonNode b,
String path, Map<String, Object[]> out) {
if (a.equals(b)) return out;
if (a.isObject() && b.isObject()) {
a.fields().forEachRemaining(e -> {
JsonNode av = e.getValue();
JsonNode bv = b.get(e.getKey());
if (bv == null) out.put(path + "/" + e.getKey(), new Object[]{av, null});
else diff(av, bv, path + "/" + e.getKey(), out);
});
b.fields().forEachRemaining(e -> {
if (a.get(e.getKey()) == null)
out.put(path + "/" + e.getKey(), new Object[]{null, e.getValue()});
});
} else if (a.isArray() && b.isArray()) {
int common = Math.min(a.size(), b.size());
for (int i = 0; i < common; i++) diff(a.get(i), b.get(i), path + "[" + i + "]", out);
for (int i = common; i < a.size(); i++) out.put(path + "[" + i + "]", new Object[]{a.get(i), null});
for (int i = common; i < b.size(); i++) out.put(path + "[" + i + "]", new Object[]{null, b.get(i)});
} else {
out.put(path.isEmpty() ? "/" : path, new Object[]{a, b});
}
return out;
}
Explanation
JSON diffing is tree traversal. Both trees are walked node by node: object keys are checked for presence on both sides, array elements are compared by index, and scalars are tested for equality. What changes between libraries is the output:
- Categorized reports (
DeepDiff,deep-object-diff, the recursive walkers above) answer “what changed?” . Ideal for tests and audits. - RFC 6902 patches (
fast-json-patch,zjsonpatch) answer “how do I turn A into B?” : a compact, replayable operation list. Use them for sync APIs and undo features.
One caveat: arrays are compared by position. [1, 2] and [2, 1] diff as two changes, even if order doesn’t matter in your domain. Sort or normalize first when that’s the case.
Worked Example
Diffing a staging config against production:
// staging.json
{ "db": { "host": "staging.db.local", "pool": 5 }, "cache": true, "features": ["search"] }
// prod.json
{ "db": { "host": "prod.db.local", "pool": 5 }, "cache": true, "features": ["search", "beta-ui"], "logLevel": "warn" }
The recursive diff reports three changes, not one blob of “files differ”:
/db/host → staging.db.local → prod.db.local
/features/1 → added: "beta-ui"
/logLevel → added: "warn"
That’s actionable: one expected environment difference, one feature someone forgot to disable, and a log level that never got documented. A single == check wouldn’t have told you any of that.
Variants
| Technology | Library | Approach | Best for |
|---|---|---|---|
| Python | deepdiff | DeepDiff(old, new) | Categorized reports, ignore_order, exclusions |
| Python | stdlib recursive | json_diff() above | Zero deps, full control of the output shape |
| JavaScript | fast-json-patch | compare(old, new) | RFC 6902 patches, reversible, compact |
| JavaScript | deep-object-diff | detailedDiff(old, new) | Simple added/updated/deleted split |
| Java | zjsonpatch | JsonDiff.asJson(a, b) | RFC 6902 via Jackson, battle-tested |
| Java | Jackson recursion | diff() above | No dependency, custom reporting |
What Works
- Normalize before diffing: sort keys and arrays when order is irrelevant;
DeepDiffacceptsignore_order=True - Exclude volatile fields: timestamps, request IDs, and counters always differ —
exclude_pathsindeepdiff, or prune the keys before comparing - Pick the output for the job: categorized report for audits, RFC 6902 when you need to replay the change
- Diff subtrees on huge documents: a deep diff on a 10MB payload is slow; compare the sections you care about or hash them first
- Commit golden snapshots: snapshot tests need baseline files in version control, reviewed like code
Common Mistakes
- Comparing floats directly:
1.0and1.00serialize identically, but floating-point noise (0.1 + 0.2) creates phantom diffs . Round or use a tolerance - Ignoring array order on purpose:
[1, 2]and[2, 1]are different JSONs; decide upfront whether order matters and normalize if it doesn’t - Diffing stringified JSON:
JSON.stringifypreserves insertion order, so{"a":1,"b":2}and{"b":2,"a":1}compare as different strings . Parse first, then diff - Treating null and missing as equal:
{"a": null}and{}are semantically different; make sure your diff reports the key as removed, not changed - Logging raw diffs: a full structural diff of a large config is unreadable and can leak secrets . Summarize paths and values, and mask sensitive fields first
When NOT to Diff This Way
- You only need equality:
==(orassertEqualson parsed nodes) answers “same or not” without the report overhead - The payload is huge: deep diffs on multi-MB documents burn CPU; compare hashes or checksums and diff only when they disagree
- Changes happen in real time: for streaming data, diffing every payload is too expensive . Validate incrementally or sample
- You need the merged result: diffing tells you what changed; it doesn’t combine the objects. Use a merge recipe for that
See Also
- RFC 6902 — JSON Patch: the standard operation format used by
fast-json-patchandzjsonpatch - RFC 6901 — JSON Pointer: the
/path/to/valuesyntax patches use for addressing - deepdiff documentation: exclusions,
ignore_order, tree view, and custom operators - Merge JSON Files: the inverse operation — combining objects instead of comparing them
- Validate JSON Schema: when you need to check shape, not differences
- Companion resources repository: runnable diffs in Python, JavaScript, and Java with sample payloads
Frequently Asked Questions
How do I ignore specific fields when diffing JSON?
Use exclusion rules. DeepDiff supports exclude_paths and exclude_regex_paths. fast-json-patch doesn't filter natively . Preprocess both objects by deleting ignored keys before comparison. For the recursive walkers, skip the key inside the loop.
Can I diff JSON while ignoring array order?
Yes. DeepDiff has ignore_order=True. In JavaScript, sort the arrays or convert them to sets before diffing. For the custom walkers, sort each array copy by a stable key before the index comparison.
What's the difference between a diff report and a JSON Patch?
A report (added/removed/updated) is human-readable and meant for inspection. A JSON Patch (RFC 6902) is a machine-readable operation list : add, remove, replace, move, copy, test. Designed to be replayed with applyPatch. Pick the report for tests and audits; pick the patch for sync and undo.
How do I generate a human-readable diff report?
DeepDiff's pretty() method produces readable output. For RFC 6902 patches, map op codes to verbs : replace → "changed", add → "added", remove → "removed". Print each with its path. The recursive walkers let you format each entry however you want.
Does diffing work on nested arrays inside objects?
Yes, as long as the algorithm recurses through both containers. The walkers above handle object-in-array and array-in-object nesting at any depth; what they can't do is match reordered array elements by identity — that's a harder problem most libraries solve only approximately.
How do I handle null versus a missing key?
They mean different things: {"a": null} explicitly sets a to null, while {} doesn't have the key at all. DeepDiff and the recursive walkers report the missing key as added/removed and the null as a value . That’s usually what you want. If your domain treats them as equal, normalize null fields out before diffing.
Related Resources
Merge JSON Files
How to merge multiple JSON files into a single object or array in Python, Java, and JavaScript.
RecipeMerge JSON Files in JavaScript
Combine multiple JSON files with conflict resolution strategies using Node.js.
RecipeValidate JSON Schema
How to validate JSON data against schemas in Python, Java, and JavaScript.
RecipeSerialize and Deserialize Data
How to serialize and deserialize data in JSON, XML, and YAML across Python, Java, and JavaScript.
RecipeDeep Clone Objects in JavaScript: Beyond JSON.parse
Compare deep clone strategies in JavaScript: JSON.parse, structuredClone, manual recursion, and libraries for nested objects with circular references.
RecipeParse JSON in Python, JavaScript and Java
Parse JSON strings into native objects in Python, JavaScript and Java — with error handling, streaming for large files and schema validation.