Skip to content

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 fieldFull-object PUT
API surfaceexplodes with the schemaone method
Payload sizetinythe whole object every time
"Clear" vs "leave alone"n/aambiguous, 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
yespresentset to the new value
yesempty/absentclear: reset to default/null
noanythingleft 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 loud InvalidArgument than a quiet overwrite.

Recap

ConcernHow the update_mask handles it
Endpoint per fieldGone: one UpdateProduct, the mask picks the fields
Partial update (PATCH)Only masked paths change; the rest is untouched
Clear a fieldList it in the mask, leave it empty in the payload
Nested fieldsDot notation: promotion.ends_at
List element editsNot supported: use dedicated add/remove methods
Empty maskRequire it: empty means full replace, i.e. data loss
Immutable / unknown pathsReject 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.

Powered by Buttondown.

Related posts

© 2026 < Denis AKPAGNONITE /> | N1BBzerLZXT