JSON Patch vs Merge Patch for Review UIs
Compare JSON Patch and Merge Patch through arrays, nulls, moves, preconditions, and the human evidence a configuration review UI needs.
JSON Patch vs Merge Patch becomes a product decision when people must review arrays, nulls, moves, and stale configuration proposals. This comparison applies both formats to one edit, proves the same final state, and shows which intent each review UI must reconstruct.
JSON Patch vs Merge Patch is a review decision
JSON Patch vs Merge Patch looks like a wire-format choice until a person must approve the change. Both formats can produce the same JSON document, but they preserve different evidence about paths, arrays, deletion, movement, and preconditions. Those differences determine what a review UI can explain without reconstructing intent.
RFC 6902 defines JSON Patch as an ordered array of operations such as add, remove, replace, move, copy, and test. RFC 7396 defines JSON Merge Patch as a document shaped like the target, where object members are merged and null removes a member. Neither specification promises a humane diff by itself.
The product question is narrower: which representation loses less of the intent your reviewers need, and can the interface safely rebuild what remains? Settle that alongside API contracts before interface polish. A polished tree that obscures a whole-array replacement or a failed precondition is still an unsafe review surface.
This comparison uses one before-and-after configuration, applies both formats, and proves that they converge on the same final state. The lab then reports the facts each format exposes. It does not crown a universal winner; it makes the missing evidence visible enough for a team to choose deliberately.
- Base
- The same versioned configuration is cloned for both applications.
- JSON Patch
- An ordered operation list preserves test, move, and path-level verbs.
- Merge Patch
- An object-shaped document replaces the array and merges members.
- Same
- Canonical final JSON and digest match for the main fixture.
Show one semantic edit through both formats
A JSON Patch vs Merge Patch comparison becomes concrete with a routing policy that has an ordered rules array, an owner object, a threshold, and an obsolete note. The proposed edit moves the shipping rule ahead of review, changes its threshold, adds an escalation channel, and removes the note. The final object is unambiguous, but the journey differs.
JSON Patch can express the sequence as test, move, replace, add, and remove operations. A reviewer sees that an existing rule moved rather than being deleted and recreated. The initial test can assert the expected rule ID or revision before mutation, giving the server a format-level guard against applying the sequence to an unexpected document.
JSON Merge Patch carries the complete replacement rules array, the nested owner addition, the threshold change, and a null for the deleted note. It is concise and readable when changes are mostly object-member updates. It does not preserve the move as an operation; the review layer must compare old and new array elements and infer whether identity survived.
The deterministic lab applies both patches to cloned inputs and compares their canonical JSON. A passing equivalence check proves the fixtures converge, not that the representations communicate equal intent. That distinction is the foundation of structured change review.
Runnable artifact — Deterministic application of the built-in example or a validated 64 KiB --fixture object with before, jsonPatch, and mergePatch; not a security certification or universal UX score.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { isDeepStrictEqual } from "node:util";
const sha = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
const clone = (value) => structuredClone(value);
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const MAX_FIXTURE_BYTES = 64 * 1024;
function tokens(pointer) {
if (pointer === "") return [];
if (typeof pointer !== "string" || !pointer.startsWith("/")) throw new Error("invalid-pointer");
return pointer.slice(1).split("/").map((raw) => { if (/~(?:[^01]|$)/.test(raw)) throw new Error("invalid-pointer"); const token = raw.replace(/~1/g, "/").replace(/~0/g, "~"); if (forbidden.has(token)) throw new Error("unsafe-pointer"); return token; });
}
function locate(document, pointer) {
const path = tokens(pointer);
if (!path.length) return { parent: null, key: "", value: document };
let parent = document;
for (const token of path.slice(0, -1)) { if (parent === null || typeof parent !== "object" || !own(parent, token)) throw new Error("missing-path"); parent = parent[token]; }
const key = path[path.length - 1];
return { parent, key, value: parent?.[key] };
}
function read(document, pointer) { const found = locate(document, pointer); if (found.parent !== null && !own(found.parent, found.key)) throw new Error("missing-path"); return clone(found.value); }
function remove(document, pointer) {
const { parent, key } = locate(document, pointer);
if (parent === null || !own(parent, key)) throw new Error("missing-path");
if (Array.isArray(parent)) { const index = Number(key); if (!Number.isInteger(index) || index < 0 || index >= parent.length) throw new Error("invalid-index"); return parent.splice(index, 1)[0]; }
const value = parent[key]; delete parent[key]; return value;
}
function add(document, pointer, value) {
const { parent, key } = locate(document, pointer);
if (parent === null) return clone(value);
if (Array.isArray(parent)) { const index = key === "-" ? parent.length : Number(key); if (!Number.isInteger(index) || index < 0 || index > parent.length) throw new Error("invalid-index"); parent.splice(index, 0, clone(value)); }
else parent[key] = clone(value);
return document;
}
function replace(document, pointer, value) { const found = locate(document, pointer); if (found.parent === null || !own(found.parent, found.key)) throw new Error("missing-path"); if (Array.isArray(found.parent) && (!Number.isInteger(Number(found.key)) || Number(found.key) < 0 || Number(found.key) >= found.parent.length)) throw new Error("invalid-index"); found.parent[found.key] = clone(value); return document; }
function validateTree(value) {
let nodes = 0;
const visit = (item, depth) => {
nodes += 1;
if (nodes > 2000 || depth > 12) throw new Error("fixture-too-complex");
if (typeof item === "string" && Buffer.byteLength(item, "utf8") > 4096) throw new Error("fixture-value-too-large");
if (Array.isArray(item)) { if (item.length > 256) throw new Error("fixture-array-too-large"); for (const child of item) visit(child, depth + 1); }
else if (isObject(item)) { const keys = Object.keys(item); if (keys.length > 128) throw new Error("fixture-object-too-large"); for (const key of keys) { if (forbidden.has(key)) throw new Error("unsafe-key"); visit(item[key], depth + 1); } }
};
visit(value, 0);
}
function validateJsonPatch(patch) {
if (!Array.isArray(patch) || patch.length > 128) throw new Error("invalid-patch");
const allowedByOperation = { test: new Set(["op", "path", "value"]), remove: new Set(["op", "path"]), add: new Set(["op", "path", "value"]), replace: new Set(["op", "path", "value"]), move: new Set(["op", "path", "from"]) };
for (const operation of patch) {
if (!isObject(operation) || typeof operation.op !== "string" || typeof operation.path !== "string" || !allowedByOperation[operation.op]) throw new Error("invalid-operation");
if (Object.keys(operation).some((key) => !allowedByOperation[operation.op].has(key))) throw new Error("unknown-operation-member");
if (["test", "add", "replace"].includes(operation.op) && !own(operation, "value")) throw new Error("invalid-operation");
if (operation.op === "move" && typeof operation.from !== "string") throw new Error("invalid-operation");
tokens(operation.path); if (operation.from !== undefined) tokens(operation.from);
}
}
function validateFixture(value) {
if (!isObject(value)) throw new Error("invalid-fixture-shape");
const expected = new Set(["before", "jsonPatch", "mergePatch"]);
if (Object.keys(value).some((key) => !expected.has(key))) throw new Error("unknown-fixture-member");
if (![...expected].every((key) => own(value, key)) || !isObject(value.before) || !isObject(value.mergePatch)) throw new Error("invalid-fixture-shape");
validateTree(value); validateJsonPatch(value.jsonPatch);
return value;
}
function readFixture(file) {
let raw;
try { raw = readFileSync(file); } catch { throw new Error("fixture-read-failed"); }
if (raw.byteLength > MAX_FIXTURE_BYTES) throw new Error("fixture-too-large");
let value;
try { value = JSON.parse(raw.toString("utf8")); } catch { throw new Error("malformed-fixture-json"); }
return validateFixture(value);
}
function applyJsonPatch(input, patch) {
let document = clone(input);
for (const operation of patch) {
if (operation.op === "test") { if (!isDeepStrictEqual(read(document, operation.path), operation.value)) throw new Error("test-failed"); }
else if (operation.op === "remove") remove(document, operation.path);
else if (operation.op === "add") document = add(document, operation.path, operation.value);
else if (operation.op === "replace") document = replace(document, operation.path, operation.value);
else if (operation.op === "move") { const value = remove(document, operation.from); document = add(document, operation.path, value); }
}
return document;
}
function applyMergePatch(target, patch) {
if (!isObject(patch)) return clone(patch);
const output = isObject(target) ? clone(target) : {};
for (const [key, value] of Object.entries(patch)) { if (forbidden.has(key)) throw new Error("unsafe-key"); if (value === null) delete output[key]; else output[key] = applyMergePatch(output[key], value); }
return output;
}
const args = process.argv.slice(2);
let alternate = false;
let fixturePath = null;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === "--alternate") { if (alternate) throw new Error("duplicate-argument:--alternate"); alternate = true; }
else if (argument === "--fixture") { if (fixturePath !== null || index + 1 >= args.length || args[index + 1].startsWith("--")) throw new Error("invalid-argument:--fixture"); fixturePath = args[index += 1]; }
else throw new Error("unknown-argument:" + argument);
}
if (alternate && fixturePath) throw new Error("conflicting-arguments");
const nextLimit = alternate ? 4 : 3;
const nextThreshold = alternate ? .81 : .72;
const builtIn = {
before: { revision: 7, rules: [{ id: "review", limit: 2 }, { id: "ship", limit: 1 }], owner: { team: "platform" }, threshold: .6, note: "legacy" },
jsonPatch: [
{ op: "test", path: "/revision", value: 7 },
{ op: "move", from: "/rules/1", path: "/rules/0" },
{ op: "replace", path: "/rules/0/limit", value: nextLimit },
{ op: "add", path: "/owner/escalation", value: "#release" },
{ op: "replace", path: "/threshold", value: nextThreshold },
{ op: "remove", path: "/note" },
],
mergePatch: { rules: [{ id: "ship", limit: nextLimit }, { id: "review", limit: 2 }], owner: { escalation: "#release" }, threshold: nextThreshold, note: null },
};
const selected = fixturePath ? readFixture(fixturePath) : validateFixture(builtIn);
const { before, jsonPatch, mergePatch } = selected;
const jsonPatchResult = applyJsonPatch(before, jsonPatch);
const mergePatchResult = applyMergePatch(before, mergePatch);
if (!isDeepStrictEqual(jsonPatchResult, mergePatchResult)) throw new Error("non-equivalent-results");
const explicitNullWithJsonPatch = applyJsonPatch({ owner: "platform" }, [{ op: "replace", path: "/owner", value: null }]);
const nullWithMergePatch = applyMergePatch({ owner: "platform" }, { owner: null });
const hostile = {};
for (const [name, run] of Object.entries({ stale: () => applyJsonPatch(builtIn.before, [{ op: "test", path: "/revision", value: 6 }]), missing: () => applyJsonPatch(builtIn.before, [{ op: "remove", path: "/missing" }]), index: () => applyJsonPatch(builtIn.before, [{ op: "add", path: "/rules/99", value: {} }]), pointer: () => applyJsonPatch(builtIn.before, [{ op: "add", path: "/__proto__/polluted", value: true }]), merge: () => applyMergePatch(builtIn.before, { constructor: { prototype: { polluted: true } } }) })) { try { run(); } catch (error) { hostile[name] = error.message; } }
const core = {
schema: "json-patch-review-receipt-v1",
fixture: fixturePath ? "validated external before/jsonPatch/mergePatch fixture" : "built-in equivalent configuration edit with array movement, nested addition, replacement, deletion, and a base precondition",
inputContract: { flag: "--fixture <path>", maximumBytes: MAX_FIXTURE_BYTES, requiredMembers: ["before", "jsonPatch", "mergePatch"], alternate: "--alternate mutates only the built-in fixture" },
before,
jsonPatch,
mergePatch,
results: { jsonPatch: jsonPatchResult, mergePatch: mergePatchResult, equivalent: true },
reviewFacts: {
jsonPatch: { orderedOperations: jsonPatch.map(({ op, path, from }) => ({ op, path, ...(from ? { from } : {}) })), explicitMove: jsonPatch.some((operation) => operation.op === "move"), inDocumentPrecondition: jsonPatch.some((operation) => operation.op === "test"), arrayEdit: jsonPatch.some((operation) => operation.op === "move") ? "move plus path-level operations" : "path-level operations", deletion: jsonPatch.some((operation) => operation.op === "remove") ? "remove operation" : "no remove operation" },
mergePatch: { topLevelMembers: Object.keys(mergePatch), explicitMove: false, inDocumentPrecondition: false, arrayEdit: "whole-array replacement when an array member is present", deletion: "null member when present" },
nullTrap: { jsonPatchResult: explicitNullWithJsonPatch, mergePatchResult: nullWithMergePatch, distinction: "Merge Patch null removes an object member; JSON Patch can store explicit null." },
},
hostile,
claimBoundary: "Deterministic application of the built-in example or a validated 64 KiB --fixture object; not a security certification or universal UX score.",
};
console.log(JSON.stringify({ ...core, receiptHash: sha(core) }, null, 2));
console.log("PASS: equivalent patch results, bounded custom fixtures, array and null semantics, hostile inputs, mutation, and digest verified");
Arrays turn convenience into ambiguity
Arrays are the strongest dividing line in JSON Patch vs Merge Patch. A JSON Patch vs Merge Patch array case exposes the difference immediately: Merge Patch treats an array as a value, so changing one element sends a replacement array. JSON Patch addresses array positions and can insert, remove, replace, or move at an index. Positional precision helps, but it can also become fragile when concurrent edits shift indexes.
For ordered objects, render identity and position separately. A row labeled “ship moved from 2 to 1” is more useful than a red block followed by a green block, provided stable IDs support that claim. When IDs are absent or duplicated, the interface should say that it inferred a match rather than presenting a move as fact.
If the product needs reversible local edits, build JSON Patch undo and redo from captured preimages rather than assuming every operation is self-inverting. A remove needs the removed value; a move needs its original path; a replace needs the previous value. Merge Patch likewise needs a before snapshot or derived inverse because the patch alone omits overwritten values.
Review the whole array when order changes execution semantics. Collapsing unchanged rows is fine only if the interface keeps the old and new index, identity key, and hidden-count summary available. The format can transport the change, but the UI owns whether its consequence is legible.
Null means deletion in a Merge Patch
In a JSON Merge Patch object, a member with value null requests removal of that member. This JSON Patch vs Merge Patch distinction makes deletion compact and creates a representational limit: the same Merge Patch cannot express “set this object member to the JSON value null.” If explicit null is meaningful in your domain, the product must use another operation, wrap the value, or choose a different contract.
JSON Patch separates those actions. A remove operation deletes the path, while add or replace can write a null value. The review UI can therefore label “removed” and “set to null” from the operation itself, assuming the path and precondition are valid.
Do not fix the ambiguity with color alone. Use verbs, old and new values, and a domain explanation: “inherit default” may be the consequence of deletion, while “explicitly unset” may be the consequence of null. Give schema migrations compatibility budgets when clients or stored patches span versions that interpret those states differently.
The lab includes a null trap beside the equivalent main fixture. It demonstrates that applying { owner: null } removes the owner member rather than preserving an explicit null. This is a specification fact, not a security test; authorization and schema validation still belong around either patch format.
| Review fact | JSON Patch | Merge Patch |
|---|---|---|
| Array reorder | Can preserve move intent | Reviewer infers from full replacement |
| Member deletion | remove operation | null member |
| Explicit null | add or replace null | Not expressible for an object member |
Preconditions belong beside the change
For JSON Patch vs Merge Patch, human approval can outlive the state it reviewed. JSON Patch offers a test operation that can fail the sequence when a path no longer has the expected value. That is useful evidence, but it does not replace HTTP conditional requests, transaction boundaries, authorization, or domain validation.
Merge Patch has no in-document test operation. Pair it with an ETag and If-Match, a version field enforced by the server, or another compare-and-swap boundary. The review interface should display the base revision and warn when the current document no longer matches it, rather than silently rebasing a previously approved change.
Record the proposal, base digest, patch media type, actor, approval, application result, and resulting digest. Audit trails are product surfaces when someone must answer what was reviewed versus what was applied. Storing only the final object makes that question needlessly hard.
For JSON Patch, decide whether a failed test aborts the entire document; RFC 6902 evaluation is sequential and failure stops successful application of the patch document. For Merge Patch, make the surrounding conditional request equally explicit. Reviewers should see “stale proposal” as a state with recovery choices, not as a generic save error.
Design the review tree for keyboard reasoning
A structured configuration review needs hierarchy, comparison, and an efficient reading path. A configuration diff UX must preserve those facts before it adds keyboard shortcuts or color. The WAI-ARIA treegrid pattern describes a composite widget that combines expandable rows with grid-like navigation. It can fit deeply nested changes, but only when the full keyboard model, focus management, and row semantics are implemented and tested.
Do not reach for a treegrid merely because the data is JSON. A static heading-and-table view may be clearer for small patches and easier to navigate with native browser behavior. Choose the least complex pattern that preserves path, action, before value, after value, array identity, and consequence.
When a treegrid is justified, keep operation order separate from visual grouping. JSON Patch evaluation order can matter even if rows are grouped under a common parent. Provide an “operation sequence” view and a “resulting document” view, then announce which one has focus. The component API should reveal product intent through concepts such as changeKind, confidence, baseRevision, and consequence—not only red and green cell props.
Every icon needs a text equivalent. Test expanded and collapsed states, long paths, large values, arrays with repeated elements, 200% zoom, forced colors, and keyboard-only approval. The review contract should survive when syntax highlighting disappears.
- Identify the base revision and patch media type before showing changes.
- Preserve JSON Patch operation order even when grouping by parent path.
- Show path, action, before value, after value, and inferred consequence.
- Mark inferred moves or identity matches with their confidence.
- Connect approval and application to the resulting digest.
Choose from lost intent and reconstruction cost
The JSON Patch vs Merge Patch decision should begin with the evidence reviewers need. Choose JSON Patch when ordered operations, targeted array edits, move semantics, or in-document tests are important and your clients can safely implement JSON Pointer and sequential evaluation. Choose JSON Merge Patch when object-shaped partial updates dominate, whole-array replacement is acceptable, and null-as-delete matches the domain. Both HTTP PATCH formats still need media-type negotiation, validation, authorization, and conditional application.
Score the decision with concrete fixtures rather than feature adjectives. The comparison is wider than a small phone, so the labeled region contains horizontal movement, accepts keyboard focus, and keeps the page itself from overflowing:
Scroll the comparison table horizontally when its three columns do not fit.
| Review fact | JSON Patch | Merge Patch |
|---|---|---|
| Path-level action | Explicit operation | Inferred from shape |
| Array movement | Can be explicit | Reconstructed from replacement |
| Delete vs explicit null | Distinct | Null means delete in objects |
| In-document precondition | test operation | External version boundary |
| Final-state readability | Requires applying sequence | Often resembles target subtree |
Add weights from the product: frequency of array edits, cost of a mistaken reorder, need for offline proposals, client diversity, and reviewer expertise. The best format is the one whose omissions the surrounding system can reconstruct honestly and test reliably.
If the weighted result is close, prototype the same three hostile changes in both formats. Measure reviewer accuracy and time, but inspect the errors qualitatively. A faster approval flow that hides stale state or null deletion is not a better outcome.
Ship the patch and its review receipt
A production proposal should carry the base digest or version, patch media type, normalized patch, human-readable change facts, validation result, actor, approval state, and application receipt. Preserve the original patch bytes when signatures or forensic review matter; store a normalized derivative separately for display.
Run the comparison lab with one real proposal encoded as a JSON object containing exactly before, jsonPatch, and mergePatch, then invoke node json-patch-review-lab.mjs --fixture ./proposal.json. The file is bounded to 64 KiB and the lab rejects unknown arguments, malformed or oversized input, unsafe keys or pointers, unsupported operations, and patches that do not reach equivalent final states. Then inspect arrays, nulls, moves, and preconditions with reviewers who understand the configuration consequence, not only its JSON shape.
Treat a format migration as a product change. Existing clients, audit logs, queued proposals, and rollback tools may depend on operation ordering or merge semantics. Publish the compatibility boundary, dual-read or conversion period, and the state after which old proposals will be rejected.
The useful conclusion is not “one RFC wins.” JSON Patch vs Merge Patch gives the review surface different raw material. Make that material explicit, test the interface against the lost-intent cases, and ship a receipt that proves the approved base, proposed transformation, and applied result stayed connected.