The problem with naive PATCH endpoints
Imagine a user resource with twenty fields. The client wants to update the user's display name. A naive PATCH implementation accepts a partial body — just the fields to update — and merges it with the stored document. This seems reasonable until you face these questions:
- If the client sends
{"avatarUrl": null}, does that mean "set avatar to null" or "delete the avatar URL"? - If the client sends
{"displayName": "Bob", "preferences": {"theme": "dark"}}, does this merge the preferences object or replace it entirely? - Can the client remove a field that was previously set? If so, how?
- How does the client atomically update a value only if it currently has a specific value?
These are not edge cases — they are the normal operations of any real application. And every team answers them differently, creating inconsistent semantics across endpoints and across APIs. JSON Patch answers all of them with a standardized, explicit, atomic operation format.
The six JSON Patch operations
RFC 6902 defines exactly six operations. Each operation is a JSON object with an op field, a path field (a JSON Pointer, RFC 6901), and optionally a value or from field. A patch document is an array of these operations, applied in order:
[
// add: adds a value at the path (creates if absent, replaces array element)
{ "op": "add", "path": "/displayName", "value": "Alice Smith" },
// remove: removes the value at the path
{ "op": "remove", "path": "/avatarUrl" },
// replace: replaces an existing value (path must already exist)
{ "op": "replace", "path": "/email", "value": "alice@newdomain.com" },
// move: moves a value from one path to another
{ "op": "move", "from": "/tmpField", "path": "/permanentField" },
// copy: copies a value from one path to another
{ "op": "copy", "from": "/preferences/theme", "path": "/cachedTheme" },
// test: asserts a value — if this fails, the entire patch is rejected
{ "op": "test", "path": "/version", "value": 7 }
]The test operation is particularly powerful. It enables optimistic concurrency: include a test for the current version or ETag value, and the patch will be rejected atomically if the resource has been modified since the client last read it. This is a built-in solution to the lost update problem that many naive PATCH implementations do not address.
A complete example: patching a user resource
Here is a realistic PATCH request using JSON Patch content type:
PATCH /api/users/42 HTTP/1.1
Content-Type: application/json-patch+json
[
{ "op": "test", "path": "/version", "value": 12 },
{ "op": "replace", "path": "/displayName", "value": "Alice Smith" },
{ "op": "add", "path": "/biography", "value": "Software engineer." },
{ "op": "remove", "path": "/legacyField" },
{ "op": "replace", "path": "/preferences/notifications", "value": false }
]The server applies this atomically. If the test fails (version is not 12), none of the other operations are applied. If any operation fails (path does not exist for replace, etc.), the entire patch is rejected and the document is unchanged. The response is the updated resource or an error, with semantically correct HTTP status codes (409 Conflict for test failures, 422 Unprocessable Entity for invalid patch operations).
Server-side implementation is straightforward with libraries:
// Node.js with 'fast-json-patch'
import * as jsonpatch from 'fast-json-patch';
app.patch('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
const patch = req.body; // array of JSON Patch operations
// Validate patch document before applying
const errors = jsonpatch.validate(patch, user);
if (errors.length) {
return res.status(422).json({ errors });
}
// Apply patch to a clone (don't mutate the original yet)
const patched = jsonpatch.applyPatch(
structuredClone(user),
patch,
true // validateOperation = true
).newDocument;
await db.users.update(req.params.id, patched);
res.json(patched);
});JSON Merge Patch: the simpler alternative (RFC 7396)
RFC 7396 defines JSON Merge Patch — a simpler approach where you send a partial object and the server merges it. Fields present in the patch replace the corresponding fields in the resource. Fields set to null in the patch are removed from the resource. Fields absent from the patch are unchanged.
PATCH /api/users/42 HTTP/1.1
Content-Type: application/merge-patch+json
{
"displayName": "Alice Smith", // replace
"legacyField": null, // remove (null = delete in merge patch)
"preferences": {
"theme": "dark" // this REPLACES the entire preferences object
}
}
// Original: { "preferences": { "theme": "light", "notifications": true } }
// After: { "preferences": { "theme": "dark" } }
// Note: 'notifications' was lost — merge patch replaces nested objects entirelyJSON Merge Patch is simpler to understand and implement but has real limitations: you cannot set a field to null without removing it, you cannot update a single field inside a nested object without replacing the whole object, and there is no optimistic concurrency mechanism.
When to use each
Use JSON Merge Patch (RFC 7396) when:
- The update semantics are simple: replace fields, delete fields by setting to null
- You do not need to update individual items inside nested arrays
- You do not need optimistic concurrency control
- The client audience includes non-technical integrators who find operation arrays intimidating
Use JSON Patch (RFC 6902) when:
- You need to set a field to null without deleting it
- You need to update individual elements in an array
- You need atomic test-and-update (optimistic concurrency)
- The patch must be applied programmatically and auditable (ops are self-documenting)
- You are building a collaborative editing feature (operational transform scenarios)
Either is better than an ad-hoc PATCH endpoint with undefined semantics. Any PATCH endpoint that is not using one of these standards should be considered technical debt with a future incident attached to it.