DEV Community

Kazuya Umeki
Kazuya Umeki

Posted on Originally published at qiita.com

PATCH vs. "Drop Null Properties": Two Google-Style Ways to Clear a Field

If your API drops null properties from its JSON, PATCH can no longer tell "clear this field" apart from "leave it alone." In this post, I'll show two ways I've solved that while still following Google's API guidelines.

When designing APIs, I often refer to Google's AIPs (API Improvement Proposals) and Style Guides.

The Google JSON Style Guide has a rule that says to "consider removing properties with null values."

In other words, for a Users resource with name and age, if age is unset (NULL), the recommended JSON looks like this:

{
  "name": "Kazuya Umeki"
}
Enter fullscreen mode Exit fullscreen mode

Adopting this rule, however, creates a problem when implementing PATCH (partial updates).

TL;DR

Yes, the "drop nulls" rule and PATCH can coexist (or so I believe).

The rule is a recommendation ("consider removing"), and it explicitly allows an exception when there's a strong semantic reason to keep the property.
You could argue that wanting to clear a value is exactly that kind of reason. But then every client has to send null on purpose, and the rule stops being a rule.

If clients also omit nulls in their requests (the rule applies to requests too), the server can no longer distinguish "not specified" from "clear this value."

So the intent has to travel through a different channel. These are the two channels I've used:

  • Approach 1: Turn "unset" into a value (enum zero value, AIP-126). Good for enum fields.
  • Approach 2: Explicitly list the fields to update (update_mask, AIP-134). Works for any type.

The problem

The "Empty/Null Property Values" rule

The Google JSON Style Guide has a rule called Empty/Null Property Values.

Consider removing empty or null values.

If a property is optional or has an empty or null value, consider dropping the property from the JSON, unless there's a strong semantic reason for its existence.

{
  "volume": 10,

  // Even though the "balance" property's value is zero, it should be left in,
  // since "0" signifies "even balance" (the value could be "-1" for left
  // balance and "+1" for right balance.
  "balance": 0,

  // The "currentlyPlaying" property can be left out since it is null.
  // "currentlyPlaying": null
}

Note: The comments in the quote are for illustration only; JSON doesn't actually allow comments.

In short: "Unless there's a particular reason, remove empty or NULL properties from the JSON."
In the example above, balance can be -1 or 1, so 0 carries meaning. My reading is that this is why it's kept rather than omitted.

The rule applies to both requests and responses.
The idea is to keep a property only when there's a strong reason (i.e., the value carries meaning).

Any policy would work, but with strictly typed languages becoming more common, it's nice to have a rule like this in place.
You don't have to agonize over undefined vs. null... until PATCH shows up.

PATCH in a nutshell

The PATCH HTTP method applies partial modifications to a resource.

PATCH request method - MDN

PUT replaces the whole resource; PATCH changes only part of it.
Whether a PATCH is idempotent depends on its design. Both approaches in this post only set values, so their requests stay idempotent and safe to retry.

Here's a side-by-side comparison:

curl -X 'PATCH' \
  -H 'Content-Type: application/json' \
  "https://api.example.com/users/{id}" \
  -d '{"name": "Kazuya Umeki"}'
Enter fullscreen mode Exit fullscreen mode
curl -X 'PUT' \
  -H 'Content-Type: application/json' \
  "https://api.example.com/users/{id}" \
  -d '{"name": "Kazuya Umeki", "age": 25}'
Enter fullscreen mode Exit fullscreen mode

Wait, we can't clear age?

Some of you may have noticed already: with PATCH, clearing age turns out to be a problem.

Say the resource is currently in this state:

{
  "name": "Kazuya U.",
  "age": 25
}
Enter fullscreen mode Exit fullscreen mode

Then a thought crosses your mind: "Hmm, I'd rather take my age off my profile."

Under the rule, sending "age": null isn't an option, so omitting it is all the client can do:

curl -X 'PATCH' \
  -H 'Content-Type: application/json' \
  "https://api.example.com/users/{id}" \
  -d '{"name": "Kazuya Umeki"}'
Enter fullscreen mode Exit fullscreen mode

But the server reads this as "A PATCH request came in. So this is a request to update name." The result:

{
  "name": "Kazuya Umeki",
  "age": 25
}
Enter fullscreen mode Exit fullscreen mode

Once a value has been entered, there's no way to clear it.


To sum up, a PATCH body inherently has three states:

State Meaning JSON following the rule
Has a value Update to this value "age": 30
NULL Clear the value Property omitted (indistinguishable)
Not specified Don't touch it Property omitted (indistinguishable)

The rule of omitting NULLs erases the distinction between "clear it" and "leave it alone."
That's the root of the problem. In TypeScript terms, it's the difference between age?: number and age?: number | null. Strictly typed languages like TypeScript and Go make you want to keep those three states apart.


In the rest of this post, I'll show how I let clients clear a field they've already set.
age can't be enumerated, so for Approach 1 I'll switch to a gender field. Approach 2 goes back to age.

Options I considered first

Before getting to my two approaches, here are the other standard options and where they clash with the rule.

  • Explicitly sending NULL ({"age": null}): It breaks the rule for requests, so every client serializer has to keep nulls on purpose. On the server, Go's encoding/json decodes both null and a missing key into a nil pointer, so telling them apart needs a custom type for every clearable field.
  • JSON Merge Patch, RFC 7396 ({"age": null} removes the property): Same problem in a standard wrapper: clients must send null, which is exactly what the rule tells them not to do. It also needs its own media type (application/merge-patch+json), and it replaces arrays wholesale.
  • JSON Patch, RFC 6902 (a remove operation in an array of operations): Very expressive, but clients have to build an operation list with JSON Pointer paths instead of sending the resource. That's a lot of ceremony for a form that edits a few fields.
  • Applying the rule to responses only ({"age": null} allowed in requests): Two rules for one API. Clients and reviewers have to remember which direction allows null, and the server still has the Go decoding problem from the first item.

To be fair, sending null isn't unheard of even at Google: its Go client libraries have a NullFields option for sending nulls in PATCH requests. It's a real trade-off. I just wanted one rule in both directions.

Approach 1: Operation AIP-126

(The "Operation" names were coined with a coworker. Google had nothing to do with them.)

AIP (API Improvement Proposals) is a set of design documents Google maintains as guidance for API design.

AIP-126 covers enums. It recommends making the first value of an enum *_UNSPECIFIED (with an exception for a useful zero value like UNKNOWN), and its own example documents that value as unused.
I deliberately gave it a meaning, "not set," so clients can send it to clear the field.

type gender string

const (
    genderUnspecified gender = "unspecified"
    genderMale        gender = "male"
    genderFemale      gender = "female"
)
Enter fullscreen mode Exit fullscreen mode

(Simplified for illustration. A real form usually needs more options; see the limitations below.)

Clearing the field is then just a normal request:

curl -X 'PATCH' \
  -H 'Content-Type: application/json' \
  "https://api.example.com/users/{id}" \
  -d '{"gender": "unspecified"}'
Enter fullscreen mode Exit fullscreen mode

At first, I was weighing how to handle optionals: "Should I use a pointer type?" "Should I keep it a string and just omit the property?"
But I realized that if I treat the unset state as a value, handling becomes consistent across the API, DB, and UI layers. (In the DB, you can keep storing NULL and map it to "unspecified" at the API boundary, so existing rows don't need a migration.)

One Go-specific catch: the zero value of gender is "", not "unspecified". That turns out to be what makes this approach work. When a client omits gender, the decoder gives you "", which should mean "leave it alone." "unspecified" is the only way to say "clear it." So don't normalize "" to "unspecified": every PATCH that omits gender would wipe it. (encoding/json can't tell an omitted field from an explicit "gender": "", so treat both as "not specified." If you unmarshal straight onto the loaded entity instead, omitted fields simply keep their current value.)

This approach has limitations:

  • It only works for fields you can enumerate. You can't do this for age.
  • Whether "not set" and "prefer not to say" can share one value depends on your requirements. If your form offers "Prefer not to say," it probably needs its own value.
  • Adding values later means old clients must tolerate unknown strings. (The JSON Style Guide recommends string enum values for exactly this reason.)

Which brings us to Approach 2.

Approach 2: Operation UpdateMask

AIP-134 (Standard methods: Update) already defines this for HTTP: the resource goes in the PATCH body, and update_mask goes in the query string.
The JSON Style Guide itself hints at the same idea: it reserves a data.fields property that lists the fields present in a partial PATCH request.
Our API isn't gRPC, but I adopted the same contract for our plain REST/JSON endpoints. I built a PoC, adopted it, and implemented it.

In short, it's a technique for explicitly listing which properties to update.

It's easier to show than explain, so take a look at the request:

curl -X 'PATCH' \
  -H 'Content-Type: application/json' \
  "https://api.example.com/users/{id}?update_mask=name,age" \
  -d '{"name": "Kazuya Umeki"}'
Enter fullscreen mode Exit fullscreen mode
  • List the properties you want to update as a comma-separated query parameter, e.g. update_mask=name,age.
  • The request body expresses the resulting state (with NULL values omitted).

From the server's perspective, it goes like this:

  • An update request for a user resource came in!
  • The query parameter says there are two targets: name and age!
  • Looking at the body, name gets updated to Kazuya Umeki, and age isn't in the body, so it gets cleared (set to NULL).

With this approach, you can distinguish "clear it" from "leave it alone" while still following the rule, which solves the original problem.

Here's how each combination is interpreted:

Request Server's interpretation
update_mask=name + body {"name": "A"} Update only name
update_mask=name,age + body {"name": "A"} Update name and clear age
update_mask=name + body {"name": "A", "age": 30} Update only name; age is ignored (AIP-161)
update_mask=age + body {"age": null} (a client ignoring the rule) Treat it the same as omitting age: clear it
No mask Update every field present in the body (AIP-134), so nothing can be cleared
update_mask=* Full replacement, like PUT (AIP-134 requires supporting it, but recommends listing fields explicitly)

The response is the updated resource, as AIP-134 recommends, so the client can see right away that age is gone.

About casing: AIP names the field update_mask, but in JSON a FieldMask is encoded as a comma-separated string of lowerCamelCase paths (e.g., profile.displayName). A practical choice is to accept both update_mask and updateMask as the parameter name, and use the same lowerCamelCase paths as the JSON body, so clients can put body keys straight into the mask.

Here's a minimal sketch of applying the mask in Go:

type User struct {
    Name string `json:"name,omitempty"`
    Age  *int   `json:"age,omitempty"`
}

// applyMask copies only the masked fields from patch onto current.
// A field that is in the mask but missing from the body gets cleared.
func applyMask(current *User, patch User, mask []string) error {
    for _, path := range mask {
        switch path {
        case "name":
            current.Name = patch.Name
        case "age":
            current.Age = patch.Age // nil when omitted → cleared (NULL)
        // "*" and nested paths are omitted for brevity.
        default:
            return fmt.Errorf("invalid update_mask path: %q", path)
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

A handler would split the query parameter on commas, call applyMask on the loaded user, and then validate the merged user before saving. That last step is what stops update_mask=name with an empty body from blanking a required field.

There are also decisions to make when implementing this:

Decision A reasonable default
Fields that must never be updated (id, permissions) appear in the mask Ignore output-only fields like id, as AIP-161 requires. Check fields the caller isn't allowed to change (e.g., roles) against an allowlist and reject them with 400.
A path in the mask doesn't exist Return 400, matching AIP-161's recommendation of INVALID_ARGUMENT for writes.
Nested fields Use dot-separated paths such as profile.displayName. AIP-161 requires allowing either the whole object or a single subfield.
Supporting * Support it, since AIP-134 requires it, but have your own clients list fields explicitly.
Is the mask required? (affects compatibility with existing clients; AIP-134 makes it optional) Keep it optional. Without a mask, only fields present in the body are updated, so existing clients keep working. They just can't clear anything.

Whatever you choose, validate the merged resource, not just the request. Otherwise, a mask can silently clear a required field.

On the client side, a simple way is to build the mask from the form's dirty fields: every field the user changed goes into the mask, and a field the user emptied is left out of the body. That's all it takes to express "clear it."

Comparing the two approaches

Approach 1: Operation AIP-126 Approach 2: Operation UpdateMask
Pros Requests stay plain JSON. Simple to implement. Works with any type. Follows AIP-134's contract.
Cons Only works for enums. Gives "unspecified" a meaning AIP-126 doesn't assign it, and takes the field out of the rule's scope. Mask and body must be kept in sync. More work for clients. Many behaviors to define.

Which one should you pick?

  • The field is an enum, and you want requests to stay plain JSON → Approach 1.
  • The field isn't an enum, or many fields need to be clearable → Approach 2.
  • You don't control the clients (e.g., a public API) → weigh Approach 2's extra client work more heavily.
  • Nothing stops you from combining them: enum fields get an explicit unspecified value, and everything else goes through the mask. If you do, let the mask win: a masked field that's missing from the body is cleared, even if it's an enum.

Wrap-up

In this post, I focused on HTTP PATCH requests and introduced two approaches.

Both approaches keep the drop-null rule intact. They just move the intent somewhere the rule doesn't reach: into the value (Approach 1) or into the mask (Approach 2). I believe a good API is one where "the operation a request wants to perform on the resource is clear." I hope you found something to take back to your own API development.

Aside: Why I lean on Google's style guides

Most major OSS projects have their own style guide, and Google publishes the ones it uses, such as the JSON Style Guide, Go Style, and the Markdown Style Guide.

I did review style guides from a few other companies, but Google's usually explain the reasons and trade-offs behind each rule. They're also well known, which lowers the cost of adopting them and makes code reviews and new projects easier. You can adopt them as-is or fork and customize them.

They're still only guidelines, though. For Go, for example, I put Effective Go and gofmt first and treat Google's guide as a supplement.

The style guides have a lot to offer, so I recommend browsing them when you need a break.

Thanks for reading all the way to the end!


Descriptions of the AIPs and the Google JSON Style Guide reflect what I checked as of September 2026. AIP content is licensed under CC BY 4.0.

Top comments (0)