Why Text Diff Fails for JSON
1// Document A (compact)2{"name":"Alice","age":30,"roles":["admin","user"]}34// 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:
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]| Operation | Purpose | Fails If |
|---|---|---|
| add | Insert a new value at path | Parent path does not exist |
| remove | Delete the value at path | Path does not exist |
| replace | Overwrite the value at path | Path does not exist |
| move | Remove from source, add to destination | Source path does not exist |
| copy | Copy value from source to destination | Source path does not exist |
| test | Assert a value equals expected (guard clause) | Value does not match |
Generating a Patch from Two Documents
1import { compare, applyPatch } from 'fast-json-patch';23const original = {4 name: 'Alice',5 age: 30,6 roles: ['user'],7};89const modified = {10 name: 'Alice',11 age: 31,12 roles: ['user', 'admin'],13 email: '[email protected]',14};1516// Generate the patch17const 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// ]2425// Apply the patch to recreate the modified version26const 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:
1// Original2const target = { name: 'Alice', age: 30, legacy_id: 'old_123' };34// Merge Patch: update age, add email, delete legacy_id5const patch = { age: 31, email: '[email protected]', legacy_id: null };67// Apply: simple recursive merge8function 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}2223const result = applyMergePatch(target, patch);24// { name: 'Alice', age: 31, email: '[email protected]' }| Feature | JSON Patch (RFC 6902) | Merge Patch (RFC 7396) |
|---|---|---|
| Format | Array of operation objects | Partial JSON document |
| Array handling | Per-element operations (add/remove at index) | Replace entire array |
| Delete fields | Explicit "remove" operation | Set to null (cannot set to actual null) |
| Atomicity | Can test before apply | No test/guard support |
| Complexity | More verbose, more precise | Simpler, less control |
| Best for | Precise patches, audit trails | Config overlays, partial updates |
Deep Merge Algorithms
Recursive Deep Merge
1function deepMerge(target, source, options = {}) {2 const { arrayStrategy = 'replace' } = options;3 const result = { ...target };45 for (const [key, sourceValue] of Object.entries(source)) {6 const targetValue = result[key];78 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 }3031 return result;32}3334// Example: merge config overlays35const 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
| Strategy | Behavior | Use Case |
|---|---|---|
| Replace | Source array replaces target array entirely | Feature flags, permission lists |
| Concat | Append source items to target array | Log entries, event streams |
| Union | Deduplicate by value (primitives) or key (objects) | Tag lists, role assignments |
| Union by key | Merge objects by matching a key field (e.g., "id") | User lists, product catalogs |
| Positional | Merge element-by-element at matching indexes | Ordered configuration steps |
Three-Way Merge
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 ]);910 for (const key of allKeys) {11 const baseVal = base[key];12 const ourVal = ours[key];13 const theirVal = theirs[key];1415 const weChanged = JSON.stringify(baseVal) !== JSON.stringify(ourVal);16 const theyChanged = JSON.stringify(baseVal) !== JSON.stringify(theirVal);1718 if (!weChanged && !theyChanged) {19 result[key] = baseVal; // no changes20 } else if (weChanged && !theyChanged) {21 result[key] = ourVal; // only we changed22 } else if (!weChanged && theyChanged) {23 result[key] = theirVal; // only they changed24 } else if (JSON.stringify(ourVal) === JSON.stringify(theirVal)) {25 result[key] = ourVal; // both changed to same value26 } else {27 // Conflict: both changed to different values28 conflicts.push({29 path: key,30 base: baseVal,31 ours: ourVal,32 theirs: theirVal,33 });34 result[key] = ourVal; // default: ours wins35 }36 }3738 return { result, conflicts };39}4041// Example42const base = { name: 'Alice', age: 30, city: 'NYC' };43const ours = { name: 'Alice', age: 31, city: 'NYC' }; // changed age44const theirs = { name: 'Alice', age: 30, city: 'London' }; // changed city4546const { result, conflicts } = threeWayMerge(base, ours, theirs);47// result: { name: 'Alice', age: 31, city: 'London' }48// conflicts: [] (no conflicts — different fields changed)Conflict Resolution Strategies
| Strategy | Rule | Best For |
|---|---|---|
| Last-write-wins | Most recent timestamp wins | Distributed caches, CRDTs |
| Priority-based | Source with higher priority wins | Config overlays (prod > staging > default) |
| Ours-wins | Local changes always win | Offline-first apps syncing back |
| Theirs-wins | Remote changes always win | Central authority model |
| Manual review | Flag conflicts for human decision | Critical data, compliance requirements |
| Field-level | Different strategies per field | Complex domain models |
Library Comparison
| Library | Language | Diff | Patch | Merge | Visual | Size |
|---|---|---|---|---|---|---|
| fast-json-patch | JS/TS | Yes | Yes (RFC 6902) | No | No | ~3 KB |
| jsondiffpatch | JS/TS | Yes | Yes | No | Yes (HTML) | ~15 KB |
| deepmerge | JS/TS | No | No | Yes | No | ~1 KB |
| json-diff | JS | Yes | No | No | CLI colorized | ~5 KB |
| rfc6902 | JS/TS | Yes | Yes (RFC 6902) | No | No | ~4 KB |
| jsonpatch (Python) | Python | No | Yes (RFC 6902) | No | No | Minimal |
| dictdiffer (Python) | Python | Yes | Yes | No | No | Minimal |
Real-World Patterns
Configuration Overlay System
1import deepmerge from 'deepmerge';23const defaultConfig = {4 db: { host: 'localhost', port: 5432, pool: 10 },5 cache: { ttl: 3600, maxSize: 1000 },6 features: { darkMode: false, beta: false },7};89const envConfig = {10 db: { host: 'prod-db.internal', pool: 50 },11 features: { darkMode: true },12};1314const runtimeOverride = {15 features: { beta: true },16};1718// Merge in order of increasing priority19const 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 = trueAPI Response Regression Testing
1import { compare } from 'fast-json-patch';2import { readFileSync, writeFileSync } from 'fs';34function snapshotTest(testName, actualResponse) {5 const snapshotPath = `__snapshots__/${testName}.json`;67 try {8 const expected = JSON.parse(readFileSync(snapshotPath, 'utf-8'));9 const diff = compare(expected, actualResponse);1011 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 snapshot19 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-patchfor 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.stringifycomparison for deep equality — key order affects the result - ✗Avoid recursive merge on untrusted input without depth limits — it can cause stack overflow