Learn/Advanced Topics

JSON Diffing & Merging — Algorithms, Libraries & Conflict Resolution

Comparing and merging JSON documents is essential for configuration management, API regression testing, collaborative editing, and data synchronization. This guide covers the RFCs, algorithms, library ecosystem, and real-world patterns you need to handle these operations correctly.

Why Text Diff Fails for JSON

Same data, different textjson
1// Document A (compact)
2{"name":"Alice","age":30,"roles":["admin","user"]}
3
4// Document B (formatted, different key order)
5{
6 "age": 30,
7 "roles": ["admin", "user"],
8 "name": "Alice"
9}

A text-based diff tool would report every line as changed. A structural JSON diff correctly reports: no differences. JSON diff tools compare the parsed data tree, ignoring whitespace and key order.

RFC 6902: JSON Patch

JSON Patch defines a format for describing changes to a JSON document as an array of operations. Each operation targets a specific path using JSON Pointer syntax:

JSON Patch operationsjson
1[
2 { "op": "replace", "path": "/name", "value": "Bob" },
3 { "op": "add", "path": "/email", "value": "[email protected]" },
4 { "op": "remove", "path": "/legacy_id" },
5 { "op": "move", "from": "/old_field", "path": "/new_field" },
6 { "op": "copy", "from": "/name", "path": "/display_name" },
7 { "op": "test", "path": "/version", "value": 2 }
8]
OperationPurposeFails If
addInsert a new value at pathParent path does not exist
removeDelete the value at pathPath does not exist
replaceOverwrite the value at pathPath does not exist
moveRemove from source, add to destinationSource path does not exist
copyCopy value from source to destinationSource path does not exist
testAssert a value equals expected (guard clause)Value does not match

Generating a Patch from Two Documents

Generate and apply JSON Patch with fast-json-patchjavascript
1import { compare, applyPatch } from 'fast-json-patch';
2
3const original = {
4 name: 'Alice',
5 age: 30,
6 roles: ['user'],
7};
8
9const modified = {
10 name: 'Alice',
11 age: 31,
12 roles: ['user', 'admin'],
13 email: '[email protected]',
14};
15
16// Generate the patch
17const patch = compare(original, modified);
18console.log(JSON.stringify(patch, null, 2));
19// [
20// { "op": "replace", "path": "/age", "value": 31 },
21// { "op": "add", "path": "/roles/1", "value": "admin" },
22// { "op": "add", "path": "/email", "value": "[email protected]" }
23// ]
24
25// Apply the patch to recreate the modified version
26const result = applyPatch(structuredClone(original), patch);
27console.log(result.newDocument);

RFC 7396: JSON Merge Patch

Merge Patch is a simpler alternative: provide a partial document, and it merges into the target. Set a field to null to delete it:

JSON Merge Patchjavascript
1// Original
2const target = { name: 'Alice', age: 30, legacy_id: 'old_123' };
3
4// Merge Patch: update age, add email, delete legacy_id
5const patch = { age: 31, email: '[email protected]', legacy_id: null };
6
7// Apply: simple recursive merge
8function applyMergePatch(target, patch) {
9 if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
10 return patch;
11 }
12 const result = { ...target };
13 for (const [key, value] of Object.entries(patch)) {
14 if (value === null) {
15 delete result[key];
16 } else {
17 result[key] = applyMergePatch(result[key], value);
18 }
19 }
20 return result;
21}
22
23const result = applyMergePatch(target, patch);
24// { name: 'Alice', age: 31, email: '[email protected]' }
FeatureJSON Patch (RFC 6902)Merge Patch (RFC 7396)
FormatArray of operation objectsPartial JSON document
Array handlingPer-element operations (add/remove at index)Replace entire array
Delete fieldsExplicit "remove" operationSet to null (cannot set to actual null)
AtomicityCan test before applyNo test/guard support
ComplexityMore verbose, more preciseSimpler, less control
Best forPrecise patches, audit trailsConfig overlays, partial updates

Deep Merge Algorithms

Recursive Deep Merge

Deep merge with configurable array strategyjavascript
1function deepMerge(target, source, options = {}) {
2 const { arrayStrategy = 'replace' } = options;
3 const result = { ...target };
4
5 for (const [key, sourceValue] of Object.entries(source)) {
6 const targetValue = result[key];
7
8 if (Array.isArray(sourceValue) && Array.isArray(targetValue)) {
9 switch (arrayStrategy) {
10 case 'replace':
11 result[key] = sourceValue;
12 break;
13 case 'concat':
14 result[key] = [...targetValue, ...sourceValue];
15 break;
16 case 'union':
17 result[key] = [...new Set([...targetValue, ...sourceValue])];
18 break;
19 }
20 } else if (
21 typeof sourceValue === 'object' && sourceValue !== null &&
22 typeof targetValue === 'object' && targetValue !== null &&
23 !Array.isArray(sourceValue)
24 ) {
25 result[key] = deepMerge(targetValue, sourceValue, options);
26 } else {
27 result[key] = sourceValue;
28 }
29 }
30
31 return result;
32}
33
34// Example: merge config overlays
35const defaults = { db: { host: 'localhost', port: 5432 }, debug: false };
36const production = { db: { host: 'prod-db.example.com' }, debug: false };
37const result = deepMerge(defaults, production);
38// { db: { host: 'prod-db.example.com', port: 5432 }, debug: false }

Array Merge Strategies

StrategyBehaviorUse Case
ReplaceSource array replaces target array entirelyFeature flags, permission lists
ConcatAppend source items to target arrayLog entries, event streams
UnionDeduplicate by value (primitives) or key (objects)Tag lists, role assignments
Union by keyMerge objects by matching a key field (e.g., "id")User lists, product catalogs
PositionalMerge element-by-element at matching indexesOrdered configuration steps

Three-Way Merge

Three-Way Merge: Base + Ours + Theirs
Three-way merge with conflict detectionjavascript
1function threeWayMerge(base, ours, theirs) {
2 const conflicts = [];
3 const result = {};
4 const allKeys = new Set([
5 ...Object.keys(base),
6 ...Object.keys(ours),
7 ...Object.keys(theirs),
8 ]);
9
10 for (const key of allKeys) {
11 const baseVal = base[key];
12 const ourVal = ours[key];
13 const theirVal = theirs[key];
14
15 const weChanged = JSON.stringify(baseVal) !== JSON.stringify(ourVal);
16 const theyChanged = JSON.stringify(baseVal) !== JSON.stringify(theirVal);
17
18 if (!weChanged && !theyChanged) {
19 result[key] = baseVal; // no changes
20 } else if (weChanged && !theyChanged) {
21 result[key] = ourVal; // only we changed
22 } else if (!weChanged && theyChanged) {
23 result[key] = theirVal; // only they changed
24 } else if (JSON.stringify(ourVal) === JSON.stringify(theirVal)) {
25 result[key] = ourVal; // both changed to same value
26 } else {
27 // Conflict: both changed to different values
28 conflicts.push({
29 path: key,
30 base: baseVal,
31 ours: ourVal,
32 theirs: theirVal,
33 });
34 result[key] = ourVal; // default: ours wins
35 }
36 }
37
38 return { result, conflicts };
39}
40
41// Example
42const base = { name: 'Alice', age: 30, city: 'NYC' };
43const ours = { name: 'Alice', age: 31, city: 'NYC' }; // changed age
44const theirs = { name: 'Alice', age: 30, city: 'London' }; // changed city
45
46const { result, conflicts } = threeWayMerge(base, ours, theirs);
47// result: { name: 'Alice', age: 31, city: 'London' }
48// conflicts: [] (no conflicts — different fields changed)

Conflict Resolution Strategies

StrategyRuleBest For
Last-write-winsMost recent timestamp winsDistributed caches, CRDTs
Priority-basedSource with higher priority winsConfig overlays (prod > staging > default)
Ours-winsLocal changes always winOffline-first apps syncing back
Theirs-winsRemote changes always winCentral authority model
Manual reviewFlag conflicts for human decisionCritical data, compliance requirements
Field-levelDifferent strategies per fieldComplex domain models

Library Comparison

LibraryLanguageDiffPatchMergeVisualSize
fast-json-patchJS/TSYesYes (RFC 6902)NoNo~3 KB
jsondiffpatchJS/TSYesYesNoYes (HTML)~15 KB
deepmergeJS/TSNoNoYesNo~1 KB
json-diffJSYesNoNoCLI colorized~5 KB
rfc6902JS/TSYesYes (RFC 6902)NoNo~4 KB
jsonpatch (Python)PythonNoYes (RFC 6902)NoNoMinimal
dictdiffer (Python)PythonYesYesNoNoMinimal

Real-World Patterns

Configuration Overlay System

Layer configs: default < environment < overridejavascript
1import deepmerge from 'deepmerge';
2
3const defaultConfig = {
4 db: { host: 'localhost', port: 5432, pool: 10 },
5 cache: { ttl: 3600, maxSize: 1000 },
6 features: { darkMode: false, beta: false },
7};
8
9const envConfig = {
10 db: { host: 'prod-db.internal', pool: 50 },
11 features: { darkMode: true },
12};
13
14const runtimeOverride = {
15 features: { beta: true },
16};
17
18// Merge in order of increasing priority
19const finalConfig = deepmerge.all([
20 defaultConfig,
21 envConfig,
22 runtimeOverride,
23]);
24// Result: db.host = 'prod-db.internal', db.port = 5432, db.pool = 50,
25// features.darkMode = true, features.beta = true

API Response Regression Testing

Snapshot diff for API regression testsjavascript
1import { compare } from 'fast-json-patch';
2import { readFileSync, writeFileSync } from 'fs';
3
4function snapshotTest(testName, actualResponse) {
5 const snapshotPath = `__snapshots__/${testName}.json`;
6
7 try {
8 const expected = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
9 const diff = compare(expected, actualResponse);
10
11 if (diff.length > 0) {
12 console.error(`Snapshot mismatch for "${testName}":`);
13 console.error(JSON.stringify(diff, null, 2));
14 throw new Error(`${diff.length} differences found`);
15 }
16 } catch (err) {
17 if (err.code === 'ENOENT') {
18 // First run: create snapshot
19 writeFileSync(snapshotPath, JSON.stringify(actualResponse, null, 2));
20 console.log(`Snapshot created: ${snapshotPath}`);
21 } else {
22 throw err;
23 }
24 }
25}

Performance Tips

  • For large documents, pre-compute hashes of subtrees to skip unchanged branches
  • Use fast-json-patch for production — it is optimized for minimal allocations
  • When diffing NDJSON, diff line-by-line instead of loading the entire file
  • For arrays of objects, sort by a stable key (ID) before diffing to reduce noise
  • Avoid JSON.stringify comparison for deep equality — key order affects the result
  • Avoid recursive merge on untrusted input without depth limits — it can cause stack overflow

Frequently Asked Questions

Why can't I use text-based diff for JSON?
Text-based diff (like Unix diff) compares lines of text, so semantically identical JSON documents with different formatting, key order, or whitespace produce false differences. JSON diff tools compare the data structure, not the text representation, giving meaningful results regardless of formatting.
What is the difference between JSON Patch and JSON Merge Patch?
JSON Patch (RFC 6902) is an array of operations (add, remove, replace, move, copy, test) that describe precise changes. JSON Merge Patch (RFC 7396) is a simpler format where you provide a partial document to merge — but it cannot distinguish between "set to null" and "delete", and cannot modify arrays precisely. Use Patch for precision, Merge Patch for simplicity.
How does deep merge handle array conflicts?
There is no universal standard for array merging. Common strategies: replace the entire array, concatenate arrays, union by a unique key (e.g., merge objects by "id"), or positional merge (element-by-element). The right strategy depends on your use case. Most libraries default to array replacement and let you customize.
What is three-way merge for JSON?
Three-way merge compares two modified versions (ours and theirs) against their common ancestor (base). Changes that only appear in one branch are auto-merged. Changes to the same path in both branches are conflicts that require manual resolution. This is the same concept as git merge, applied to JSON documents.
Which JavaScript library should I use for JSON diffing?
fast-json-patch for RFC 6902 compliance and maximum performance. jsondiffpatch for visual diff display with HTML output. json-diff for simple command-line comparison. deepmerge for merge-only operations without diff tracking. Choose based on whether you need the diff output (patch), visual display, or just the merged result.
How do I diff large JSON documents efficiently?
For documents over 10 MB, avoid naive recursive comparison. Use hash-based change detection: compute a hash for each subtree and only recurse into subtrees with different hashes. Libraries like fast-json-patch are optimized for this. For extremely large files, consider diffing at the NDJSON line level instead.