I'll be honest — I spent years writing PATCH endpoints that just took the whole object and merged it server-side. You probably have too. It works. Until it doesn't.
You get concurrent edits. You get race conditions. You get a client that sends { "role": null } because it forgot to exclude the field. And your "partial update" is silently nuking data.
Then I found RFC 6902 — JSON Patch. And it solved exactly this problem in a way I hadn't thought about.
What JSON Patch actually is
Instead of sending the new version of an object, you send a description of the changes. A list of operations: what to add, remove, replace, move.
Here's the difference in practice:
❌ The old way — send the whole thing
PATCH /users/42
Content-Type: application/json
{
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
"preferences": {
"theme": "dark",
"notifications": true
}
}
✅ With JSON Patch — send only what changed
PATCH /users/42
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/role", "value": "admin" },
{ "op": "replace", "path": "/preferences/theme", "value": "dark" }
]
Two fields changed. Two operations. Nothing else touched.
The six operations
[
{ "op": "add", "path": "/tags/0", "value": "urgent" },
{ "op": "remove", "path": "/draft" },
{ "op": "replace", "path": "/status", "value": "active" },
{ "op": "move", "from": "/tmp/name", "path": "/name" },
{ "op": "copy", "from": "/template", "path": "/doc" },
{ "op": "test", "path": "/version", "value": 3 }
]
The test op is the one most people ignore. It fails the whole patch if the value at the path doesn't match — which gives you optimistic locking for free. If /version isn't 3, nothing gets applied. That's huge for concurrent edits.
A real-world scenario
Imagine a collaborative config editor. Two users open the same document. User A changes the timeout. User B changes the retry count. With a full-object PUT, whoever saves last wins and overwrites the other's change.
With JSON Patch + test:
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/timeout", "value": 5000 }
]
If someone else already committed and bumped the version to 8, this patch fails with a 409. The client knows to re-fetch and retry. Clean, explicit, auditable.
The diff problem — and how I handle it
The annoying part is writing patches by hand. When you're prototyping or debugging, you just want to paste two JSON states and see what changed.
The compare() output is exactly what a JSON Patch generator produces — a minimal array of ops describing the diff between two states. In production, you generate this programmatically. During prototyping, you're usually diffing by hand to understand what your code should be producing.
For production use, you don't write patches manually — you generate them programmatically from a diff.
Libraries worth knowing
For JavaScript/Node.js, fast-json-patch is the go-to:
import * as jsonpatch from 'fast-json-patch';
const before = { name: "Alice", role: "user" };
const after = { name: "Alice", role: "admin" };
const patch = jsonpatch.compare(before, after);
// [{ op: "replace", path: "/role", value: "admin" }]
const result = jsonpatch.applyPatch(before, patch).newDocument;
For Python, jsonpatch works the same way — make_patch() to diff, apply() to execute.
Things that will bite you
-
JSON Pointer paths (RFC 6901) use
/as separator. A key containing a slash or tilde must be escaped:~→~0,/→~1. This silently fails if you forget. -
Array indices are fragile.
/items/2means the third element right now — if the array shifts between diff and apply, you're patching the wrong element.-as index means "append", which is usually safer. -
Skip the
testop and you lose the main concurrency benefit. It's optional in the spec, but you probably want it in any multi-user scenario.
When should you actually use this?
Honestly, not everything needs JSON Patch. If you're building a simple CRUD app with a single user, PATCH with a partial object is fine. JSON Patch earns its complexity when you have: concurrent editors, audit logs that need to record exactly what changed, sync protocols (think CRDTs, OT), or large objects where sending the whole thing is wasteful.
If any of those apply — it's worth the RFC read.
Are you using JSON Patch in production? I'm curious how people handle the array index fragility problem specifically — do you use stable IDs in your patch paths, or do you diff differently? Drop it in the comments 👇
Top comments (0)