Protobuf FieldMask for Writes: One Update Endpoint, No Ambiguity
July 7, 2026 · 6 min read · Lire en français
Contents
Protobuf FieldMask for Writes: One Update Endpoint, No Ambiguity
In the first part, FieldMask let a client say "return only these fields" so the server could skip expensive work on reads. The same little type does something just as useful on the write side and there the stakes are higher, because a mistake doesn't waste CPU, it corrupts data.
This part is about mutations: updating a product without an endpoint per field, and without accidentally erasing the fields you didn't mean to touch. Same online-store example as before, same tool, opposite direction.
The two bad options
You want to let clients change a product. Take this message:
message Product {
string id = 1;
string title = 2;
Money price = 3;
string description = 4;
Promotion promotion = 5; // nested: { discount_pct, ends_at }
}
Without FieldMask you're stuck between two unpleasant designs.
Option 1: one RPC per field. UpdateTitle, UpdatePrice, UpdateDescription, UpdatePromotionEndDate… Every new field is a new method, a new handler, a new line in the client. The API grows without bound and every consumer has to keep up.
Option 2: one Update that takes the whole object (PUT). Simpler surface, but now the client must send the complete product every time, even to change one field. Worse, it's ambiguous: if the incoming description is empty, did the client mean "clear the description" or "I just didn't set it"? With a full-replace PUT, "didn't set it" and "set it to empty" look identical, so a client that fetches, changes the price, and sends the object back can silently wipe any field it didn't repopulate.
| One RPC per field | Full-object PUT | |
|---|---|---|
| API surface | explodes with the schema | one method |
| Payload size | tiny | the whole object every time |
| "Clear" vs "leave alone" | n/a | ambiguous, data loss risk |
The mask makes it a clean PATCH
Add an update_mask and the ambiguity disappears:
service ProductService {
rpc UpdateProduct(UpdateProductRequest) returns (Product);
}
message UpdateProductRequest {
Product product = 1;
google.protobuf.FieldMask update_mask = 2;
}
The rule is one sentence: the server changes only the fields listed in the mask, and ignores everything else in the payload. To bump the price and nothing else:
product { price { amount: 1999 currency: "EUR" } }
update_mask { paths: "price" }
title, description, promotion are absent from the mask, so the server leaves them exactly as they are in storage, even though they're empty in the payload. The client sends only what it touches. That's PATCH, expressed in one standard field.
Set and clear with the same mechanism
Here's the elegant part. The mask says which fields change; the payload says what to. Combine them and you get both "set" and "clear" from one design:
Field in update_mask? | Value in product? | Result |
|---|---|---|
| yes | present | set to the new value |
| yes | empty/absent | clear: reset to default/null |
| no | anything | left untouched |
So to raise the price and remove the promotion's end date in a single call:
product { price { amount: 1999 currency: "EUR" } }
update_mask { paths: "price" paths: "promotion.ends_at" }
price is in the mask with a value → updated. promotion.ends_at is in the mask but has no value in the payload → cleared. Everything else → untouched. One request, a set and a clear, no ambiguity.
On the server, you walk the mask and apply each path:
func (s *server) UpdateProduct(ctx context.Context, req *pb.UpdateProductRequest) (*pb.Product, error) {
mask := req.GetUpdateMask()
if mask == nil || len(mask.GetPaths()) == 0 {
return nil, status.Error(codes.InvalidArgument, "update_mask is required")
}
current, err := s.load(ctx, req.GetProduct().GetId())
if err != nil {
return nil, err
}
for _, path := range mask.GetPaths() {
switch path {
case "id", "created_at":
return nil, status.Errorf(codes.InvalidArgument, "field %q is immutable", path)
case "title":
current.Title = req.GetProduct().GetTitle()
case "price":
current.Price = req.GetProduct().GetPrice()
case "description":
current.Description = req.GetProduct().GetDescription()
case "promotion.ends_at":
current.Promotion.EndsAt = req.GetProduct().GetPromotion().GetEndsAt() // nil → cleared
default:
return nil, status.Errorf(codes.InvalidArgument, "unknown field %q", path)
}
}
return s.save(ctx, current)
}
The protobuf runtime also ships a merge helper (FieldMaskUtil.merge() in Java, equivalents elsewhere) that copies masked paths from one message onto another for you, handy, but writing the switch by hand makes the set/clear/reject logic explicit, which on writes is worth the few extra lines.
Nested fields, and the repeated-field wall
Nested fields work through dot notation: promotion.ends_at targets one leaf inside the sub-message without replacing the whole promotion. You can go as deep as your schema. This is exactly why the mask beats a flag soup: nesting is free.
Repeated fields are the wall. A path can name a repeated field, but it can't reach inside it. Put recommendations in the mask and you replace the entire list, because there is no recommendations[2].title to patch a single element. If you need element-level operations (add one, remove one, reorder), FieldMask won't give them to you; model them as their own methods (AddRecommendation, RemoveRecommendation). Know this before you design an "editable list" around a mask.
The empty-mask trap: the opposite of reads
This is the one that bites, and it's the mirror image of the read behavior. On a read, an empty mask conventionally means "give me everything", a safe, friendly default. On a write, that same convention is dangerous: an empty mask means "every field in the payload is an intended change", i.e. full replace. Any field the client didn't populate gets cleared.
Now add schema evolution. You ship a new warranty field. An old client, written before warranty existed, fetches a product, changes the price, and sends it back with no mask. Its payload has no warranty. Full-replace semantics dutifully wipe the warranty on every product that old client updates. Silent, widespread data loss, and nobody wrote a bug.
Two defenses, use both:
- Require the mask on writes. Reject an update with an empty
update_mask(as the handler above does). A client must state its intent explicitly; "change everything" should never be the accident-default. - Reject unknown and immutable paths. Fail on a path you don't recognize, and on fields that must never change through this method (
id,created_at, server-computed/output-only fields). Better a loudInvalidArgumentthan a quiet overwrite.
Recap
| Concern | How the update_mask handles it |
|---|---|
| Endpoint per field | Gone: one UpdateProduct, the mask picks the fields |
| Partial update (PATCH) | Only masked paths change; the rest is untouched |
| Clear a field | List it in the mask, leave it empty in the payload |
| Nested fields | Dot notation: promotion.ends_at |
| List element edits | Not supported: use dedicated add/remove methods |
| Empty mask | Require it: empty means full replace, i.e. data loss |
| Immutable / unknown paths | Reject with InvalidArgument |
FieldMask is the same tiny type in both directions. On reads it selects what comes back and lets you skip expensive work; on writes it selects what changes and gives you clean PATCH semantics for free. The difference in stakes is the whole lesson: a forgotten guard on a read wastes a little compute, the same slip on a write can erase data. Make the mask required, reject what shouldn't be touched, and the one-endpoint update becomes both simpler and safer than the alternatives.
Back to part 1: Protobuf FieldMask for reads →.
Subscribe to future posts
Get future posts in your inbox. No spam, unsubscribe any time.
Related posts
Protobuf FieldMask: Let Your API Clients Ask for Only What They Need
One endpoint, many callers, some very expensive fields. Protobuf FieldMask lets each client declare exactly which fields it wants, so your server can skip the costly database queries, remote calls and payload it doesn't need. The idea, a classic example, and the traps.
July 3, 2026
Deploy Express.js API using Docker
How to deploy Express.js RESTFull API using Docker, with a focus on MongoDB performance, docker multi-stage builds, Jest for testing, and Nginx as a reverse proxy.
April 11, 2024
Mastering NestJS: Unlocking the Power of Relationships with TypeORM and SQL Databases
Unlock the Power of Data Relationships with NestJS, TypeORM, and SQL Databases. Master the art of building complex data structures and seamless interactions. Ideal for both seasoned NestJS developers and beginners looking to create cutting-edge apps
October 18, 2023