JSON Patch vs JSON Merge Patch Explained
You want to change one field on a resource, so you reach for HTTP PATCH. Then you have to decide what the request body looks like, and there are two standards for it: JSON Patch (RFC 6902) and JSON Merge Patch (RFC 7396). They look nothing alike, they fail in different ways, and picking the wrong one usually shows up later as "why can't I set this field to null?" This post shows how each format works, where each one breaks, and how to apply them in JavaScript, Python and kubectl.
The same change, two ways
Start with a user record from an API:
You want to rename the user, drop the beta flag, and add an admin role.
As a JSON Merge Patch (Content-Type: application/merge-patch+json):
As a JSON Patch (Content-Type: application/json-patch+json):
The merge patch is a partial copy of the document. The JSON Patch is a list of instructions. That difference drives everything else.
How JSON Merge Patch works
The rules fit in one paragraph. Walk the patch object. For each key, if the value is null, delete that key from the target. If the value is an object, recurse into it. Anything else, including an array, replaces the target value outright. If the patch itself is not an object, it replaces the whole document.
Here is the full algorithm in TypeScript. It is short enough that you rarely need a library:
Two consequences catch people out.
First, you cannot set a field to null. {"middleName": null} means "delete middleName", not "store null". If your API treats absent and null as different states, merge patch cannot express the difference. We covered why that distinction matters in optional vs nullable JSON fields.
Second, arrays are all or nothing. To add one role you have to send the whole array, which means you need to have read it first. If two clients each add a role at the same time, the second write silently drops the first one's change.
How JSON Patch works
A JSON Patch is an array of operations applied in order. There are six:
| op | What it does | Example |
|---|---|---|
add | Insert a value, or append to an array with - | {"op":"add","path":"/roles/-","value":"admin"} |
remove | Delete a key or array element | {"op":"remove","path":"/settings/beta"} |
replace | Overwrite a value that must already exist | {"op":"replace","path":"/name","value":"Ada Lovelace"} |
move | Remove from one path, add at another | {"op":"move","from":"/email","path":"/contact/email"} |
copy | Duplicate a value to another path | {"op":"copy","from":"/name","path":"/displayName"} |
test | Fail the whole patch unless the value matches | {"op":"test","path":"/version","value":7} |
/roles/0 is the first role, /settings/theme is a nested key. Two characters need escaping inside a key: ~ becomes ~0 and / becomes ~1. So a key named a/b is addressed as /a~1b. If you are used to $.settings.theme from JSONPath queries, note that JSON Pointer has no wildcards or filters. Every operation targets exactly one location.
The patch is atomic. If any operation fails, say a replace on a path that does not exist, the whole patch is rejected and the document stays unchanged. Combined with test, that gives you optimistic concurrency inside the request body:
If someone else bumped the version to 8 first, the test fails and nothing is written. Merge patch has no equivalent, so you would need an If-Match header and an ETag.
JSON Patch can also store an explicit null, because {"op":"replace","path":"/middleName","value":null} is unambiguous.
Which one to pick
| JSON Merge Patch | JSON Patch | |
|---|---|---|
| Body shape | Partial document | Array of operations |
Set a field to null | Not possible | Yes |
| Append to an array | Resend the whole array | add with /- |
| Atomic preconditions | No | test op |
| Readable by humans | Very | Fine for short patches |
| Easy to generate from a form | Yes | Needs a diff step |
test precondition. Most "partial update" endpoints you meet in the wild behave like merge patch. Kubernetes accepts both, plus its own strategic merge patch.
Applying patches in code
In JavaScript, fast-json-patch handles RFC 6902 and can also generate a patch by diffing two documents:
applyPatch mutates the document you pass in by default, which is why the example clones it first. Forgetting that is a common source of "my original object changed" bugs.
In Python, the jsonpatch package does the same, and returns a new document unless you ask for in-place:
For merge patch in Python, port the TypeScript function above. It is about ten lines and avoids a dependency.
With kubectl, the --type flag picks the format:
Without --type, kubectl uses strategic merge patch, which merges some lists by key (containers by name, for example) instead of replacing them. It only works on built-in Kubernetes types, not on custom resources, which is why --type=merge is the usual choice for CRDs.
Debugging a patch that did the wrong thing
When a patch produces an unexpected result, compare the document before and after rather than reading the patch. Paste both versions into the JSON diff tool and you get every added, removed and changed path. That usually shows the problem straight away: an array that was replaced instead of appended to, or a key that vanished because a merge patch sent null.
If the server rejects the body outright, check that it is valid JSON before blaming the patch logic. Run it through the JSON validator, then format it so each operation sits on its own line. A trailing comma after the last operation is the most common cause of a 400 here.
One last gotcha: replace requires the target to exist, while add on an existing object key quietly overwrites it. If you are not sure the field is present, use add. Use replace when a missing field should be an error.