The Problem JSON Patch Solves
Updating part of a resource over HTTP has three common approaches, and two of them have real problems. Resending the entire resource (PUT) works but wastes bandwidth and risks clobbering a field someone else updated concurrently. A bespoke "partial update" JSON body works until every client and server has to agree on an undocumented convention for what a missing field means versus an explicit null. JSON Patch (RFC 6902) is the standardized middle ground: an explicit, ordered list of operations describing exactly what changed.
The Six Operations
[
{ "op": "add", "path": "/active", "value": true },
{ "op": "remove", "path": "/legacyField" },
{ "op": "replace", "path": "/role", "value": "senior engineer" },
{ "op": "move", "from": "/tempName", "path": "/name" },
{ "op": "copy", "from": "/address", "path": "/billingAddress" },
{ "op": "test", "path": "/version", "value": 3 }
]
| Operation | What it does |
|---|---|
add | Inserts a value at path. On an object, creates or overwrites a key. On an array, inserts at that index (shifting later elements right). |
remove | Deletes the value at path. |
replace | Equivalent to a remove followed by an add at the same path โ the target must already exist. |
move | Removes the value at from and adds it at path โ a single atomic operation, not two separate ones you write yourself. |
copy | Like move, but leaves the original at from untouched. |
test | Asserts the value at path equals the given value โ if it doesn't match, the entire patch application fails before any further operations run. |
Paths Are JSON Pointers (RFC 6901), Not JSONPath
This trips people up specifically because both look similar and both show up in JSON tooling. A JSON Patch path is a JSON Pointer:
/user/address/city โ object property access, slash-separated
/items/2 โ array index (zero-based)
/items/- โ "append" โ only valid with add
/a~1b โ the literal key "a/b" โ ~1 escapes a literal slash
/m~0n โ the literal key "m~n" โ ~0 escapes a literal tilde
JSONPath ($.user.address.city, used by tools like the JSONPath Tester) is a separate, more expressive query language for finding values. JSON Pointer is deliberately simpler โ it addresses exactly one location, with no wildcards or filtering โ which is exactly the precision a patch operation needs.
Writing the Actual HTTP Request
PATCH /api/users/42 HTTP/1.1
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/role", "value": "senior engineer" },
{ "op": "add", "path": "/tags/-", "value": "rust" }
]
The Content-Type: application/json-patch+json header matters โ it's a distinct media type from application/json, and a server implementing RFC 6902 correctly may reject a patch body sent with the generic JSON content type, since that's conventionally reserved for a full-resource representation (what a PUT would send) or a JSON Merge Patch.
Generating a Patch From a Before/After Example
Hand-writing operations for a large nested object is tedious and error-prone. The practical approach: take your "before" document, make the change you want in a copy, and diff the two into a patch automatically.
before: { "name": "Jane Doe", "role": "engineer", "tags": ["backend", "go"] }
after: { "name": "Jane Doe", "role": "senior engineer", "tags": ["backend", "go", "rust"] }
generated patch:
[
{ "op": "replace", "path": "/role", "value": "senior engineer" },
{ "op": "add", "path": "/tags/2", "value": "rust" }
]
This is almost always faster and less error-prone than writing the operations by hand, especially for anything beyond a one-field change.
Why a Patch Fails to Apply
The most common failure mode: the patch was generated against (or hand-written assuming) a different version of the document than the one it's actually being applied to.
Error: Path "/role" does not exist โ cannot replace.
Every operation except add expects its target path to already exist in the document. If a field was renamed, removed, or the document structure otherwise drifted between when the patch was created and when it's applied, the operation at that path fails โ and a well-implemented patch applier should name the exact path and what it expected to find there, not fail silently or apply a partial, inconsistent result.
This is exactly what the test operation is for in a concurrent-update scenario: assert the document is still at the version you expect before making changes, so a conflicting concurrent update causes a clean, explicit failure instead of silently overwriting someone else's change.
Quick Reference
| Question | Answer |
|---|---|
| Which media type for the HTTP body? | application/json-patch+json |
| How do I target an array element? | /arrayName/2 (zero-indexed) |
| How do I append to an array? | /arrayName/- with op: "add" |
How do I escape a / or ~ in a key name? | ~1 for /, ~0 for ~ |
| How do I guard against a stale/concurrent update? | A test operation asserting the expected current value, before your real changes |
Try It
ToolNinja's JSON Patch Tool โ generates a patch from two example documents, or applies an existing patch to a document and shows the resulting output โ with a specific error naming the exact path when a patch doesn't apply cleanly. JSON Diff Checker โ also exports its comparison as a JSON Patch directly, if you just need the diff in that format.
Sources: