PUT vs JSON Merge Patch vs JSON Patch: modeling partial updates in OpenAPI without the null ambiguity
PATCH is the most under-specified verb in real APIs. One team treats it as "send the fields you changed," another expects the full resource, and a third uses null to mean both "leave this alone" and "clear this value," so clients cannot win. The HTTP standard deliberately does not define patch semantics; it only says the media type does. Pick a concrete patch format, put its media type in the OpenAPI, and the ambiguity disappears.
PUT and PATCH answer different questions
| PUT | PATCH | |
|---|---|---|
| Meaning | Replace the entire resource at the URL | Apply a set of changes to the resource |
| Body | The complete new representation | A patch document in a defined format |
| Idempotent | Yes, by definition | Depends on the format and operations |
| Missing fields | Reset to default or rejected | Merge Patch: untouched; JSON Patch: explicit |
| Good for | Full replacement, upsert | Small edits to large resources |
A PUT that silently ignores fields the client omitted is a bug, because the client believes it replaced the resource. If you want partial updates, use PATCH and say which patch language you speak.
JSON Merge Patch (RFC 7386)
Media type: application/merge-patch+json. The patch looks like the resource, but with three rules that catch people out.
- A present field replaces the server's value.
- A field set to
nulldeletes it. - Arrays are replaced wholesale; there is no element-by-element merge.
Given the stored resource:
{ "name": "Acme", "tags": ["vip", "mfg"], "address": { "city": "Berlin", "zip": "10115" } }and this merge patch:
{ "name": "Acme GmbH", "address": { "city": "Munich" }, "tags": ["vip"], "creditLimit": null }the result is:
{ "name": "Acme GmbH", "tags": ["vip"], "address": { "city": "Munich" } }Note that address.zip is gone, because nested objects merge but the patch supplied a new address containing only city; tags is the single-element array, not an append; and creditLimit was deleted. Merge Patch is simple and readable, but it cannot express "set this field to null" or "append one array item" without sending the whole collection.
JSON Patch (RFC 6902)
Media type: application/json-patch+json. The body is an ordered array of operations addressed by JSON Pointer paths. The whole sequence is atomic; any one failure applies nothing.
[
{ "op": "replace", "path": "/name", "value": "Acme GmbH" },
{ "op": "add", "path": "/tags/-", "value": "mfg" },
{ "op": "remove", "path": "/creditLimit" },
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/address/zip", "value": "80331" }
]
| Operation | Effect |
|---|---|
add | Insert a value; /tags/- appends to an array |
remove | Delete a field or array element |
replace | Replace an existing value |
move / copy | Relocate or duplicate a value with from |
test | Assert a current value; abort the batch if it differs |
The test operation is the standout feature: it builds optimistic concurrency into the patch itself. The client asserts the version it read, and the server rejects the batch with 409 or 412 if another write landed first, without a separate If-Match header. JSON Patch is more verbose than Merge Patch, but it is the only one that edits arrays precisely and records intent an audit log can replay.
How to choose
| Need | Choose |
|---|---|
| Full replace or client-side upsert | PUT |
| Simple field edits, human-written clients, small objects | Merge Patch |
| Append/remove specific array items | JSON Patch |
| Explicit null that means "store null" | PUT, or JSON Patch replace |
| Atomic multi-field change with a version guard | JSON Patch with test |
| Consumers that cannot build operation arrays | Merge Patch |
Supporting both is reasonable for different endpoints, but do not accept both media types on one route with subtly different null handling; clients will confuse them.
Modeling both in OpenAPI
paths:
/customers/{customerId}:
patch:
operationId: patchCustomer
parameters:
- $ref: '#/components/parameters/CustomerId'
- name: If-Match
in: header
required: false
schema: { type: string }
description: ETag from the last GET; recommended for Merge Patch.
requestBody:
required: true
content:
application/merge-patch+json:
schema:
$ref: '#/components/schemas/CustomerMergePatch'
application/json-patch+json:
schema:
type: array
minItems: 1
items: { $ref: '#/components/schemas/JsonPatchOperation' }
responses:
'200':
description: Updated resource
content:
application/json:
schema: { $ref: '#/components/schemas/Customer' }
'204':
description: Applied, no body returned
'409': { description: Version conflict from a test op or If-Match }
'422': { $ref: '#/components/responses/ValidationError' }For Merge Patch, the request schema is deliberately looser than the full Customer (all fields optional), and you document the null-means-delete rule in its description. Do not reuse the full resource schema with required intact, or the contract demands fields the client is allowed to omit. For JSON Patch, define JsonPatchOperation once with the op enum, path, optional from, and optional value, and reference it everywhere.
What clients, mocks, and AI agents do
Codegen turns the two media types into two methods or two accepted body types, so the choice is explicit at compile time. A spec-driven mock can apply a Merge Patch or a JSON Patch sequence to a fixture and return the patched result, which lets you test the tricky cases: deleting with null, appending to an array, and a failing test that must roll the whole batch back.
AI agents benefit from the explicit format more than anyone. Given "PATCH with partial JSON and null means ignore," models send inconsistent bodies; given application/json-patch+json and an operation schema, they emit a deterministic, replayable change set. Treating the patch language as part of the contract removes the largest source of AI-generated update bugs.
Common mistakes
- Documenting
PATCHwithapplication/jsonand no patch rules, leaving null undefined. - Reusing the full resource schema for a Merge Patch body so "optional" fields are marked required.
- Expecting Merge Patch to append arrays; it replaces them.
- Using null to mean both "clear the field" and "no change" on the same endpoint.
- Applying JSON Patch operations one by one without atomicity, leaving half-applied state.
- Making
PATCHcasually idempotent claims; a Merge Patch that only sets values is idempotent, but anaddto/tags/-is not, so document per-operation behavior.
Checklist
- Every
PATCHdeclares a concrete media type and patch language. - Merge Patch documents null-as-delete and array replacement; its request schema is all-optional.
- JSON Patch defines the operation schema and is applied atomically.
- Concurrency uses
If-Matchfor Merge Patch and atestop (or both) for JSON Patch. - Responses distinguish
200with the new body,204,409/412, and422. - Mocks apply the patch to a fixture and verify delete, append, and rollback cases.
Name the patch format and "what does null mean" stops being a support ticket.
Patch a fixture with both media types, run the conflict cases against a mock, and generate explicit client methods, in the browser app. Tightening how fields can change over time is a compatibility decision too; catch it before release with the OpenAPI breaking-change diff in CI guide.