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

PUTPATCH
MeaningReplace the entire resource at the URLApply a set of changes to the resource
BodyThe complete new representationA patch document in a defined format
IdempotentYes, by definitionDepends on the format and operations
Missing fieldsReset to default or rejectedMerge Patch: untouched; JSON Patch: explicit
Good forFull replacement, upsertSmall 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.

  1. A present field replaces the server's value.
  2. A field set to null deletes it.
  3. 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" }
]
OperationEffect
addInsert a value; /tags/- appends to an array
removeDelete a field or array element
replaceReplace an existing value
move / copyRelocate or duplicate a value with from
testAssert 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

NeedChoose
Full replace or client-side upsertPUT
Simple field edits, human-written clients, small objectsMerge Patch
Append/remove specific array itemsJSON Patch
Explicit null that means "store null"PUT, or JSON Patch replace
Atomic multi-field change with a version guardJSON Patch with test
Consumers that cannot build operation arraysMerge 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 PATCH with application/json and 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 PATCH casually idempotent claims; a Merge Patch that only sets values is idempotent, but an add to /tags/- is not, so document per-operation behavior.

Checklist

  1. Every PATCH declares a concrete media type and patch language.
  2. Merge Patch documents null-as-delete and array replacement; its request schema is all-optional.
  3. JSON Patch defines the operation schema and is applied atomically.
  4. Concurrency uses If-Match for Merge Patch and a test op (or both) for JSON Patch.
  5. Responses distinguish 200 with the new body, 204, 409/412, and 422.
  6. 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.