A PATCH endpoint is unsafe if it cannot tell “leave this field alone” from “set this field to false.” That distinction must survive decoding, validation, authorization and persistence. Otherwise, an older client updating one setting can erase a field it has never heard of.
Start with the result you expect
Suppose a profile starts as {"id":"p7","enabled":true,"retryLimit":3,"address":"Main","billingMode":"invoice"}. An older client knows nothing about billingMode. For this example, choose a documented contract: send JSON in the request body and a required update_mask query parameter naming the fields to update. Only masked fields change. A masked field must be present in the body; otherwise the request fails. For address, explicit null means clear the nullable stored value to database NULL, and GET returns "address":null.
Each row below starts from that same original profile. A successful response returns the resulting resource; GET must return the same representation.
Request | Expected response and subsequent GET | Persisted state |
|---|---|---|
|
| Only |
|
|
|
|
| The address column becomes |
| Error; a subsequent GET returns the original profile. | No write occurs. |
The meaning of null here is a product decision, not an HTTP rule. Clearing a nullable column, removing a member from a JSON document and resetting a string to "" are three different operations. If clients need more than one of them for the same field, this example contract is insufficient; give them distinct operations instead. Reject the unknown path before mutation rather than silently accepting a request that appears to work.
PATCH names an operation, not its field semantics
HTTP PATCH applies instructions from a patch document. It does not prescribe the document format or make every JSON object a valid partial update. The API has to specify how a client expresses intent. That is the practical difference from treating the body as a replacement resource: a partial update must not overwrite fields merely because they are absent from the request.
A common failure starts before the database. The server decodes JSON into a full-resource object, where a missing boolean and an explicit false both become the same in-memory value. Validation then runs against that object, and an ORM save writes every property. Neither the client nor the database can recover the distinction after decoding has discarded it.
Write the field contract before choosing a DTO. For every writable field, decide what omission, null, false, 0, "" and a non-empty value mean. Do not use “empty” as a catch-all: zero can be a valid limit, an empty string can be a valid value, and null may be prohibited entirely. Document the response representation and stored state, not just the accepted request. If a field cannot be cleared, reject the attempted clear explicitly.
Choose a patch format that fits the resource
Field masks are one solution, not a requirement of PATCH. Three useful choices make different promises:
Format | Missing value and null | Nested objects and arrays | Good fit |
|---|---|---|---|
JSON Merge Patch, | Omitted object member stays unchanged; | Object members can be patched recursively; arrays are replaced as values. | Primarily object-shaped documents where removing a member is the intended meaning of null. |
JSON Patch, | Intent is in operations: | Paths can target nested members and array elements. | Clients need explicit removal, null replacement or element-level array changes. |
Documented field-mask contract, such as this article’s | Unmasked field stays unchanged; a masked-but-missing value is rejected here. Explicit null clears only fields whose contract permits it. | Named nested fields can be supported; a repeated field is generally targeted as a whole, not by index. | Resource-oriented updates where clients can name the fields they intend to write. |
Choose Merge Patch when its concise object semantics match the product. Do not use it to express “persist JSON null” for a member when null means removal under that format. Choose JSON Patch when clients genuinely need to distinguish remove from replace with null, or to address an array element. Its extra operation syntax buys that precision; it is not automatically better for a form that edits two scalar fields.
A field mask makes write scope conspicuous, which is valuable when clients have different versions of a resource schema. It also adds a second thing—the mask—that clients must construct correctly. If your resource is an ordinary JSON document and Merge Patch already expresses every required change, that complexity may not pay for itself.

If you choose masks, settle the edge cases first
Google’s AIP-134 Update pattern puts the resource in a PATCH body and allows an update_mask. In that pattern, an omitted mask means populated, non-empty fields are updated, while a supplied empty mask should do nothing. Those are conventions for that API pattern, not defaults supplied by HTTP PATCH. This example instead requires the mask and rejects an absent or empty one. Neither choice is safe if clients have to guess it.
Specify the transport and grammar. Here, update_mask=enabled,retryLimit is a comma-separated query value, and the body is JSON. Protocol Buffers FieldMask uses a comma-separated JSON encoding for paths; its update semantics leave fields outside the mask untouched even when supplied in the request, and allow a masked field to be reset with a supplied default value. For this example, ignore supplied unmasked values rather than writing them, and test that behavior. A team might prefer to reject such inconsistent requests in its own contract, but it must never accidentally persist those values.
The masked-but-missing case needs an equally explicit rule. A Go field-mask example treats both {"address":null} and {} with update_mask=address as clearing a string to "". That is its chosen behavior, not a general field-mask requirement. This article’s contract rejects {} with that mask. Otherwise, a client that forgets to serialize address can turn an apparent no-op into a destructive edit.
Keep a writable-path allowlist. Decide whether notifications.email.enabled is valid, whether masking notifications replaces the whole object, and whether parent and child paths may appear together. Reject duplicate or conflicting paths before mutation. AIP-161 permits dotted nested message paths and a repeated field as a whole, but not an indexed path into a repeated field. Map-entry traversal and collection wildcards require custom processing rather than standard field-mask utilities. If clients need indexed array edits, reconsider JSON Patch instead of inventing mask syntax.
Reject unknown and read-only paths, including fields added to GET responses but not made writable. Never assume * means full replacement just because another API supports it: that convention needs service-level handling and a documented contract. Path validation should be versioned and reviewed like any other part of the public API.
Carry intent through decoding, checks and storage
Use an intent-aware request representation: retain field presence separately from its decoded value, then parse and validate the requested paths. For each targeted path, check write authorization and validate the submitted operation. Compute a candidate resource from the current resource and the requested changes; validate that resulting resource, not only the individual inputs. Persist the candidate atomically, then return the representation a subsequent GET will produce.
The order matters. A mask limits which fields may be written; it does not grant permission to write them. A user authorized to change address may not be authorized to change billingMode. Nor does a mask protect against another writer changing address between read and save. For updates that depend on a known version, use a conditional request such as If-Match, as described by RFC 5789. PATCH must apply the entire patch atomically or apply none of it: a valid first change followed by an invalid second change must leave the resource untouched.
Inspect the persistence layer with the same skepticism as the decoder. A full-resource ORM save may write stale, unmasked fields even when application code intended a narrow update. Conversely, a database update that drops false or zero values can silently ignore requested changes. When contract design and these cross-layer tests become implementation work, they belong in the backend development review rather than being left to a controller method alone.
In Go, test the JSON package actually deployed instead of inferring presence behavior from a Go type. The Go JSON migration documentation describes v1/v2 differences around null decoding and omitted values; encoding/json/v2 arrived in Go 1.27. The linked field-mask example uses Go 1.25 as its context, not the current release: the release history lists Go 1.27.3 as of November 3, 2026. The contract is language-agnostic; serializer behavior is not.
Run this review before clients depend on PATCH
Write one baseline resource and requests for omission,
null,false,0,""and a non-empty value. Record the expected HTTP response, stored state and subsequent GET for each.Choose Merge Patch, JSON Patch or a mask based on required null, nested-object and array operations. Publish the media type and exact request examples.
If using a mask, specify its location, whether it is required, allowed paths, masked-but-absent behavior and what happens to supplied unmasked values. Reject unknown, read-only, duplicate and conflicting paths as the contract requires.
Test from raw HTTP bytes through decoding, authorization, validation and persistence. Include nested paths, arrays, a failed validation after an otherwise valid change, and proof that the entire patch rolls back.
Send a stale
If-Matchrequest separately. Confirm that the version precondition prevents a stale write; do not treat a narrow mask as a substitute for concurrency control.