Your PATCH /users/42 endpoint receives {"nickname": null}. Does that mean "clear the nickname" or "the client didn't care about nickname"? If your answer is "it depends on the handler," you have a partial-update bug waiting to happen.
HTTP gives you two standardized formats for this: JSON Merge Patch (RFC 7396) and JSON Patch (RFC 6902). Here's how they differ, when to use each, and how to implement both correctly.
Why not just PUT?
PUT replaces the whole resource. Clients must send every field, which means read-modify-write cycles, race conditions between concurrent editors, and large payloads for tiny changes. PATCH lets clients send only what changed — but how they describe the change needs a contract.
JSON Merge Patch (RFC 7396)
Merge Patch looks like a partial copy of the resource. Media type: application/merge-patch+json.
PATCH /users/42 HTTP/1.1
Content-Type: application/merge-patch+json
{
"nickname": null,
"address": { "city": "Osaka" }
}
The rules are simple:
- Keys present with a value → set that value
- Keys present with
null→ delete the field - Keys absent → leave untouched
- Objects merge recursively; arrays are replaced wholesale
The whole algorithm fits in a few lines:
function mergePatch(target, patch) {
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
return patch; // primitives and arrays replace outright
}
const result =
target && typeof target === 'object' && !Array.isArray(target)
? { ...target }
: {};
for (const [key, value] of Object.entries(patch)) {
if (value === null) delete result[key];
else result[key] = mergePatch(result[key], value);
}
return result;
}
const user = { name: 'Aki', nickname: 'aki-chan', address: { city: 'Tokyo', zip: '100-0001' } };
console.log(mergePatch(user, { nickname: null, address: { city: 'Osaka' } }));
// { name: 'Aki', address: { city: 'Osaka', zip: '100-0001' } }
Limitations: you can't set a field to a literal null (null means delete), and you can't append to an array — you must send the entire new array.
JSON Patch (RFC 6902)
JSON Patch is a list of operations. Media type: application/json-patch+json.
PATCH /users/42 HTTP/1.1
Content-Type: application/json-patch+json
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/address/city", "value": "Osaka" },
{ "op": "add", "path": "/tags/-", "value": "beta-tester" },
{ "op": "remove", "path": "/nickname" }
]
Operations are add, remove, replace, move, copy, and test. Paths use JSON Pointer (RFC 6901), and /tags/- means "append to the array." The whole patch is atomic: if any operation fails, nothing is applied.
That test op is underrated — it gives you optimistic concurrency right inside the payload.
You don't need to hand-roll this. With the fast-json-patch package in an Express handler:
import express from 'express';
import jsonpatch from 'fast-json-patch';
const app = express();
app.use(express.json({ type: ['application/json', 'application/*+json'] }));
app.patch('/users/:id', async (req, res) => {
const user = await db.users.find(req.params.id);
if (!user) return res.status(404).end();
const type = req.get('Content-Type') || '';
let updated;
try {
if (type.startsWith('application/json-patch+json')) {
// validate ops, apply to a clone, throw on failed "test"
updated = jsonpatch.applyPatch(structuredClone(user), req.body, true).newDocument;
} else if (type.startsWith('application/merge-patch+json')) {
updated = mergePatch(user, req.body);
} else {
res.set('Accept-Patch', 'application/merge-patch+json, application/json-patch+json');
return res.status(415).end();
}
} catch (err) {
// failed "test" op → conflict; malformed patch → unprocessable
const status = err.name === 'TEST_OPERATION_FAILED' ? 409 : 422;
return res.status(status).json({ title: 'Patch could not be applied', detail: err.message });
}
// Never skip this: patched output must still pass your schema
const errors = validateUser(updated);
if (errors.length) return res.status(422).json({ title: 'Invalid result', errors });
// Protect read-only fields from being patched
updated.id = user.id;
updated.createdAt = user.createdAt;
await db.users.save(updated);
res.json(updated);
});
Status codes that matter
| Situation | Status |
|---|---|
| Unsupported patch format |
415 Unsupported Media Type + Accept-Patch header |
| Malformed patch document | 400 Bad Request |
| Patch is valid but result breaks the schema | 422 Unprocessable Content |
test op or If-Match ETag fails |
409 Conflict / 412 Precondition Failed
|
Advertise what you support with Accept-Patch on OPTIONS or GET responses so clients can discover it.
Which one should you pick?
- Merge Patch for typical CRUD forms: human-readable, trivial for clients to build, great for flat-ish resources.
- JSON Patch when you need array manipulation, explicit nulls, atomic multi-step changes, or built-in concurrency checks.
-
Both, dispatched by
Content-Type, if your API serves diverse clients. The handler above costs you ~10 extra lines.
Whatever you choose, document it. "Send the fields you want to change" is not a contract — null semantics and array behavior are where integrations silently break.
Wrapping up
Partial updates are easy to get almost right. Picking a standard format, validating the result (not just the input), and returning precise status codes turns PATCH from a guessing game into a predictable contract.
If you want to try both formats side by side, apikumo lets you save PATCH requests with different Content-Type headers in one collection, inspect the responses, and publish the examples straight into your API docs — so your consumers see exactly how null and arrays behave before they write a line of code.
Top comments (0)