JSON Toolkit

JSON Pointer, Patch, and Merge Patch

Bottom line: Three short specs form a change protocol: Pointer locates, Patch edits precisely, Merge Patch merges partially. Choosing wrong hurts arrays and concurrency.

Intermediate~12 min readPublished 2026-10-30Updated 2026-10-30
Cover for JSON Pointer, Patch, and Merge Patch

Bottom line: use JSON Pointer to locate by position and JSONPath to filter or recurse; hand array insert/remove, null writes, and version assertions to JSON Patch, and reserve Merge Patch for flat configuration and form resources.

Locating and modifying are two different things, and the specifications split them into three documents. The usual price of confusing them is "the whole array got replaced" and nobody notices.

1. JSON Pointer: The Shortest Locating Syntax

RFC 6901 does exactly one thing: point at a node in a document with a string.

""             → the whole document
"/store"       → the store member under the root
"/store/book"  → one level deeper
"/store/book/0/title" → item 0 of the array

Three rules:

  1. The empty string points at the root node itself, not at an "empty node";
  2. Each / separates one reference token; arrays use decimal indices (leading zeros are not allowed, so 01 is invalid);
  3. Inside a token, ~ is written as ~0 and / as ~1 — replace ~ before /, otherwise you end up double-escaping.
/**
 * Escapes a single reference token into a JSON Pointer segment.
 * @param token Original key name or index
 * @returns Escaped token
 */
function escapePointerToken(token: string): string {
  return token.replace(/~/g, '~0').replace(/\//g, '~1');
}

/**
 * Joins an array of key names into a JSON Pointer.
 * @param tokens Array of key names or indices
 * @returns Pointer string beginning with /
 */
function buildPointer(tokens: Array<string | number>): string {
  return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}

The value shows best when the key itself contains /: the pointer for {"a/b": 1} is /a~1b, not /a/b (the latter means b under a).

When used inside a URL, percent-encoding is also required, because # and ~ have special meanings in a fragment.

2. JSON Patch: An Atomic Sequence of Six Operations

RFC 6902 describes changes with an array of operations, media type application/json-patch+json:

[
  { "op": "test", "path": "/version", "value": 7 },
  { "op": "replace", "path": "/name", "value": "新的名字" },
  { "op": "add", "path": "/tags/-", "value": "json" },
  { "op": "remove", "path": "/temp" },
  { "op": "copy", "from": "/name", "path": "/alias" },
  { "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
Operation Semantics Boundary
add Adds a member or inserts an array element The array index may equal the length (append); - means the end
remove Removes The path must exist
replace Replaces an existing value The path must exist (equivalent to remove + add, but requires the path to exist first)
move Moves You cannot move a node under its own subpath
copy Copies The source must exist
test Asserts equality If it fails, the whole patch does not apply; used for optimistic locking

The key property is atomicity: if any operation fails, the whole patch rolls back, so there is no intermediate state where "the first three applied and the fourth failed". This makes it a great PATCH request body.

2.1 Using test for optimistic concurrency

[
  { "op": "test", "path": "/etag", "value": "a3f9c1" },
  { "op": "replace", "path": "/status", "value": "paid" }
]

The client first GETs the etag and writes it into the test operation. On concurrent writes, the second request's test fails and the server returns 409 Conflict or 412 Precondition Failed. This is far safer than last-write-wins and requires no distributed lock.

2.2 Array operations are the most error-prone

// Want to insert at index 1
{ "op": "add", "path": "/items/1", "value": {...} }   // Correct: insert; the original index 1 and everything after shift back

// Want to replace index 1
{ "op": "replace", "path": "/items/1", "value": {...} } // Correct: replace

add at array index n is an insert, not an overwrite; an out-of-range index (n > length) must raise an error. Some implementations are lenient about this, which makes the same patch behave differently across servers.

3. Merge Patch: A Fragment That Looks Like the Target

RFC 7386 has only four rules:

  1. The target is an object and the patch is an object → merge recursively;
  2. A key's value in the patch is null → delete that key from the target;
  3. A key's value in the patch is not an object → replace directly;
  4. The patch itself is not an object → the whole target is replaced.
// Target
{ "a": "b", "c": { "d": "e", "f": "g" } }

// Patch
{ "a": "z", "c": { "f": null } }

// Result
{ "a": "z", "c": { "d": "e" } }

The media type is application/merge-patch+json. It reads like a "partial update", which makes it especially easy to misuse:

  • Arrays can only be replaced wholesale. Want to delete list[2]? Merge Patch cannot do it;
  • You cannot write null. null is already taken to mean deletion;
  • It cannot express a third state between "leave this field alone" and "delete this field" (for example, explicitly setting it to empty).

4. Capability Matrix: Which One to Choose

Requirement JSON Patch Merge Patch
Precisely replace a specific array element ✓ ✗
Delete one item from an array ✓ ✗
Partially update several fields ✓ (multiple replace) ✓ (the most concise)
Write a null value ✓ ✗
Concurrency control (version assertion) ✓ (test) ✗ (only via If-Match)
Patch readability Moderate Good (looks like the data itself)
Implementation complexity High Low
Suitable resource shapes Deeply nested, list-like Flat configuration, forms

Rules of thumb:

  • Forms, settings pages, configuration resources → Merge Patch; the frontend only submits the changed fields;
  • List additions and removals, state machine transitions, anything needing concurrency protection → JSON Patch;
  • Both can coexist: different endpoints declare different Content-Types, and the server dispatches by type.

5. Implementation Notes

import { applyPatch, compare } from 'fast-json-patch';

const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });

// test operations are validated by default; setting mutateDocument to true modifies in place, use with caution
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);

Four easily overlooked points:

  1. Don't mutate shared objects in place; a failed patch leaves a half-modified state (unless the implementation guarantees rollback);
  2. Path validation: patches from outside must validate every path/from and reject keys such as __proto__ and constructor;
  3. Size limits: cap the number of patch entries to prevent a single patch from performing hundreds of thousands of operations;
  4. Auditing: patches are naturally suited to audit logs — they are a complete description of "what changed".

6. Error Semantics

Situation Suggested status code Explanation
The patch is not an array / is structurally invalid 400 Bad Request The request body format is wrong
The node referenced by path does not exist 422 Unprocessable Entity Syntactically valid but semantically not executable
A test operation failed 409 or 412 Indicates a version conflict
No permission to modify that path 403 Forbidden Don't mask it with 404 unless you deliberately hide the resource's existence

Distinguishing 400 from 422 matters: the former means "I can't understand your patch", the latter means "I understand it, but this data does not allow that change".

7. Checklist

  • [ ] The endpoint declares the correct Content-Type (json-patch+json / merge-patch+json);
  • [ ] Patch application is atomic, and a failure leaves no partial result;
  • [ ] External patches are validated against a path whitelist and an entry-count limit;
  • [ ] Concurrency control uses test or If-Match;
  • [ ] Patches are written to the audit log;
  • [ ] Array scenarios explicitly choose JSON Patch; don't count on Merge Patch.

FAQ

How do I decide between JSON Patch and Merge Patch?

Use JSON Patch when you need to replace or remove a specific array element, write a null value, or assert a version; use Merge Patch for forms and configuration resources that only update a few fields. Merge Patch cannot delete a single array item, and it cannot write null, because null already means deletion.

Why is add at array index n not an overwrite?

Because add inserts into an array: the element at index n and everything after it shift back. To replace an existing element you must use replace, and the path has to exist first. An out-of-range index (n > length) should raise an error; lenient implementations make the same patch behave differently across servers.

Should I modify the original object in place?

No. A failed patch leaves a half-modified state unless the implementation guarantees rollback. Apply the patch to a deep-copied or structured-cloned copy, then swap the reference after success.