A JSON diff is a change model, not merely a highlighted screen. It must decide whether object member order matters, how arrays are matched, how paths are encoded, which values may be revealed, and whether the result is only a report or an executable patch. Different answers can all be reasonable for different domains.
Key Takeaways
- Parse JSON before comparing; whitespace and object-member order should not create semantic changes.
- Use JSON Pointer paths and escape member names. A dotted path is ambiguous when keys contain dots, slashes, or brackets.
- Arrays require a policy: positional comparison, LCS-style edit matching, or domain-key matching. “Smart” matching is not a universal semantic truth.
- A diff report is not automatically a valid JSON Patch. Test patch application and validate the resulting document.
- LCS and nested matching can become expensive. Bound depth, node count, array length, and output operations for untrusted input.
- Redact secrets in diff output, define normalization for volatile fields, and preserve enough provenance to reproduce the comparison.
What a Structural Diff Produces
After parsing, a comparator walks two JSON values and emits changes such as:
| Operation | Meaning |
|---|---|
add |
A value exists only in the right document |
remove |
A value exists only in the left document |
replace |
Both locations exist but their values differ |
move |
An existing array or object value changes location |
copy |
A value is duplicated at another location |
test |
A precondition must match before applying a patch |
The last three are JSON Patch operations, not requirements for every human-readable diff. A report may intentionally use a simpler vocabulary.
Comparison Contract
Document these decisions before selecting an algorithm:
- Are object keys unordered, and are duplicate keys rejected during parsing?
- Are arrays ordered, treated as sets, or matched by a unique field such as
id? - Are numbers compared by parsed value, lexical representation, or a decimal type?
- Are
null, missing fields, empty strings, and defaulted fields distinct? - Should timestamps, generated IDs, signatures, or secret values be normalized or redacted?
- Is the output for a person, a review system, or an automated patch consumer?
- What are the input and output limits?
Without this contract, two tools can produce different but internally consistent diffs for the same documents.
Object Comparison
Objects can be compared by taking the union of their keys, sorting the traversal for deterministic output, and recursively comparing values at matching keys. Use an own-property check so a language runtime’s prototype chain cannot masquerade as JSON data.
The path format matters. JSON Pointer (RFC 6901) escapes ~ as ~0 and / as ~1, and uses / as a separator. This makes keys such as a/b and a.b unambiguous.
Array Matching Algorithms
Positional Comparison
Compare index i on the left with index i on the right. This is predictable and cheap, but an insertion near the beginning can make every subsequent item appear changed. It is often correct for tuples, ordered priority lists, and time series.
Longest Common Subsequence
LCS can identify insertions and removals in an ordered sequence. A classic dynamic-programming implementation uses O(n × m) time and memory for arrays of lengths n and m; optimized variants trade memory or produce different tie-breaking. LCS needs an equality or similarity predicate, and equal-looking objects may still have distinct identities.
Key-Based Matching
Match objects by a stable domain key such as id, then recursively diff matching records. Define duplicate and missing-key behavior, type changes, and whether moving a record is itself meaningful. Do not use a guessed key or stringify arbitrary objects as an identity proof.
Set-Like Comparison
Treating an array as a set discards order and may discard multiplicity. It is valid only when the domain contract says so and defines duplicate handling. Sorting by serialized text is not a general set algorithm.
A Bounded JavaScript Diff
The following example performs deterministic object comparison, positional array comparison, JSON Pointer escaping, and a node budget. It is intentionally not a minimal LCS or keyed matcher; production code should choose the policy explicitly.
const own = (value, key) =>
Object.prototype.hasOwnProperty.call(value, key);
const escapePointer = (token) =>
String(token).replaceAll("~", "~0").replaceAll("/", "~1");
const childPath = (path, token) =>
`${path}/${escapePointer(token)}`;
function diffJson(left, right, path = "", state = { nodes: 0, limit: 10000 }) {
state.nodes += 1;
if (state.nodes > state.limit) throw new RangeError("diff node limit exceeded");
if (Object.is(left, right)) return [];
const leftObject = left !== null && typeof left === "object";
const rightObject = right !== null && typeof right === "object";
if (!leftObject || !rightObject || Array.isArray(left) !== Array.isArray(right)) {
return [{ op: "replace", path, from: left, to: right }];
}
if (Array.isArray(left)) {
const changes = [];
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const location = childPath(path, index);
if (index >= left.length) {
changes.push({ op: "add", path: location, value: right[index] });
} else if (index >= right.length) {
changes.push({ op: "remove", path: location, value: left[index] });
} else {
changes.push(...diffJson(left[index], right[index], location, state));
}
}
return changes;
}
const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort();
const changes = [];
for (const key of keys) {
const location = childPath(path, key);
if (!own(left, key)) {
changes.push({ op: "add", path: location, value: right[key] });
} else if (!own(right, key)) {
changes.push({ op: "remove", path: location, value: left[key] });
} else {
changes.push(...diffJson(left[key], right[key], location, state));
}
}
return changes;
}
This code does not parse JSON, redact values, match arrays by identity, or guarantee that its operations can be applied in sequence. Those are separate contracts, not details to hide inside a recursive function.
JSON Patch and Application Safety
JSON Patch (RFC 6902) defines an ordered operation sequence addressed by JSON Pointer. A generated patch must be tested by applying it to the left document and comparing the result with the intended right document. Array indices are especially sensitive: removing an earlier index changes the meaning of later indices, so operation ordering matters.
Use test operations when a precondition is required, and treat patch application as a state-changing operation with authorization, size limits, audit records, and failure handling. Never apply a diff to production configuration solely because a model or UI labels it “safe”.
JSON Merge Patch (RFC 7396) has different semantics: null can mean deletion in an object patch, so it cannot represent every application-level null value. Do not substitute it for JSON Patch without checking the consumer contract.
Applications
API Regression Tests
Compare a stable projection or schema-aware response rather than every volatile timestamp, request ID, or signature. Store a redacted diff and classify changes as contract failures, expected data changes, or formatting noise.
Configuration Review
Compare both source and effective configuration when defaults or inheritance are involved. A semantic diff can show the effective change, while a source diff explains why it happened. Secret values should be represented as changed without being copied into the report.
Synchronization
A diff can reduce a synchronization payload, but the receiver still needs version checks, authorization, idempotency, conflict handling, and a way to reject stale patches. A smaller patch is not automatically a safe patch.
Version Control and Migration
Use deterministic traversal and a documented array policy to reduce review noise. For migrations, validate invariants and record the source revision, schema version, diff policy, and application result.
Performance and Resource Controls
Before comparing untrusted documents, limit bytes, nesting depth, object members, array length, node visits, candidate matches, operation count, and serialized output. A quadratic LCS or all-pairs object matcher can become a denial-of-service vector.
For large documents, stream where possible, compare partitioned records, hash only under a defined canonicalization policy, or use domain indexes. A hash can detect identical bytes or canonical values; it cannot explain a semantic diff or prove that two entities represent the same real-world object.
Redaction and Provenance
Diffs often expose both old and new values, making them sensitive even when the source files were protected. Apply field-aware redaction before persistence, avoid logging full payloads, and ensure redaction does not change the decision semantics unexpectedly. Record:
- source identifiers and revisions;
- parser, comparator, and policy versions;
- normalization and redaction rules;
- limits and failure states;
- output hash, reviewer or actor, timestamp, and patch application result.
Frequently Asked Questions
Why does a JSON diff show a whole array as changed?
The comparator may use positional equality, or it may lack a stable identity policy. An insertion near the beginning shifts later indices. Use LCS or key-based matching only when their assumptions match the domain.
Is LCS always the best array algorithm?
No. LCS is useful for ordered sequences, but it can be expensive and does not know business identity. Positional comparison may be correct for tuples, while key-based matching is better for records with unique IDs.
Does JSON Patch always produce the smallest change?
No. Minimality depends on the diff algorithm, array policy, tie-breaking, and operation cost model. A shorter patch can also be harder to review or riskier to apply.
Can a diff tool compare secrets safely?
Only with an explicit data-flow and redaction policy. Prefer comparing fingerprints or presence/type metadata, and keep raw values out of logs and persisted diff artifacts.
Can I apply a generated diff automatically?
Only after validating the patch against the expected source revision, authorizing the target, enforcing limits, and testing the resulting document. A human-readable diff is not proof that an automated side effect is safe.
Primary Sources
- RFC 8259: JSON Data Interchange Syntax
- RFC 6901: JSON Pointer
- RFC 6902: JSON Patch
- RFC 7396: JSON Merge Patch
- OWASP: Secrets Management Cheat Sheet
Conclusion
A serious JSON diff implementation makes semantics, identity, paths, limits, redaction, and patch application explicit. Choose the simplest algorithm that matches the domain, measure its worst case, and validate every executable change against the expected source revision. A diff should improve understanding and controlled synchronization, not create a new source of ambiguity or side effects.