API Design

Resource Design Patterns · Lesson 04

Partial updates & field masks

PUT is a wrecking ball — send it with two fields and every other field disappears. Field masks give clients a precise scalpel: an explicit list of exactly which fields to touch, leaving everything else untouched.

⏱ 13 min Difficulty: core Prereq: Standard methods (Lesson 02), Versioning (rel-01)

By the end you'll be able to

The problem: PUT clobbers fields you didn't mention

HTTP PUT carries full-replacement semantics: the request body is the new resource. Omit a field, and the server treats that omission as "clear this to null" — because, per the spec, the body is the authoritative representation.

Here is a concrete failure mode. A task in the Tasks API has this current state:

{
  "name": "projects/proj_42/tasks/task_77",
  "title": "Draft API spec",
  "notes": "See Notion doc for context",
  "status": "open",
  "due_time": "2025-06-20T17:00:00Z",
  "assignee": "users/ada",
  "labels": ["backend", "spec"]
}

A mobile client only needs to update the title and due date, so it issues:

PUT /v1/projects/proj_42/tasks/task_77 HTTP/1.1
Content-Type: application/json

{
  "title": "Finalise API spec v2",
  "due_time": "2025-06-25T17:00:00Z"
}

The server dutifully replaces the resource with exactly what it received. notes, assignee, and labels are gone — they were not in the body, so they are now null/empty. The mobile client did not even know about notes: it was added after the client shipped and the SDK was never updated. One PUT, three fields silently wiped.

This is not a hypothetical edge case. It is the most common silent data-loss pattern in real-world API integrations — especially when older SDK versions are in the wild. Standard methods (Lesson 02) covers why PUT and PATCH have distinct roles; this lesson focuses on the PATCH half and how to make it unambiguous.

PATCH to the rescue — but which fields?

PATCH's formal semantics (RFC 5789) say the request body is a set of instructions for how to modify the resource, not a replacement. In JSON API practice, this usually means "the fields present in the body are the ones I want to change." Under that interpretation, the mobile client's problem is solved: send only title and due_time, and the server updates only those two.

But PATCH alone introduces its own ambiguity. Consider this body:

PATCH /v1/projects/proj_42/tasks/task_77 HTTP/1.1
Content-Type: application/json

{
  "due_time": null
}

Does "due_time": null mean "clear this field — I want the task to have no due date"? Or does it mean "I am serialising my object and null is the default for a field I don't care about — leave whatever was there"? There is no way for the server to tell the difference from the body alone. And if an older client simply omits due_time entirely, is that also "leave it" or "clear it"?

Implicit mask — inferring "update only fields present in the body" — handles the common case but breaks down the moment a client needs to clear a field. You can't omit a field to mean "clear it" (indistinguishable from "don't touch it") and you can't null it without the server guessing your intent.

The update_mask solution

The pattern, originally specified in Google's AIP-134 and used by the Google Cloud APIs, is an update_mask query parameter. Its value is a comma-separated list of field paths — the exact set of fields the server should look at and potentially change.

PATCH /v1/projects/proj_42/tasks/task_77?update_mask=title,due_time HTTP/1.1
Content-Type: application/json

{
  "title": "Finalise API spec v2",
  "due_time": "2025-06-25T17:00:00Z"
}

The semantics are now unambiguous:

Field paths follow dot-notation for nested fields. A task's assignee is an object with sub-fields; to update only the user ID inside it, you would write update_mask=assignee.user_id. To replace the entire assignee object, you write update_mask=assignee — which covers all its sub-fields as a unit.

This pattern also has a natural home in the Protocol Buffers FieldMask message, which is why it appears throughout the Google Cloud client libraries. The AIP specification uses proto field paths directly — the same names appear in both the proto schema and the update_mask query string.

PATCH without update_mask Server infers mask from body — ambiguous title ✓ in body due_time ✓ in body due_time: null notes (omitted) labels (omitted) → written → written → cleared? or unchanged? → unchanged (probably) → unchanged (probably) PATCH with update_mask=title,due_time Explicit mask — unambiguous intent title ✓ in mask due_time ✓ in mask due_time: null + in mask notes not in mask labels not in mask → written → written → cleared (intentional) → untouched → untouched
Left: without update_mask the server must guess whether a null value means "clear" or "don't touch". Right: with an explicit mask the intent is unambiguous — masked fields are written (or cleared), unmasked fields are never touched.

Worked example: PATCH without mask (implicit mask — the risky version)

An implicit-mask server infers the mask from whatever fields appear in the request body. In the happy path this works fine, but it has a silent failure mode: any field the client does not know about is automatically excluded from the inferred mask — even if the server later adds new fields that older clients never serialise.

# Implicit mask — the server updates only what it sees in the body
PATCH /v1/projects/proj_42/tasks/task_77 HTTP/1.1
Content-Type: application/json

{
  "title": "Finalise API spec v2",
  "due_time": "2025-06-25T17:00:00Z"
}

HTTP/1.1 200 OK
{
  "name": "projects/proj_42/tasks/task_77",
  "title": "Finalise API spec v2",
  "notes": "See Notion doc for context",   // ← preserved (server inferred mask from body)
  "status": "open",
  "due_time": "2025-06-25T17:00:00Z",
  "assignee": "users/ada",
  "labels": ["backend", "spec"],
  "update_time": "2025-06-18T15:00:00Z"
}
// But if this client is an older SDK that doesn't know about labels[],
// it will omit them — and an implicit-mask server will wipe them.
⚠️ The implicit-mask trap

When your server ships a new field (say labels) and a client that was compiled before that field existed sends a PATCH without a mask, the server sees no labels in the body and — under an implicit mask — treats this as "client says labels should be cleared." The client never intended that. The field was not even in its schema. This exact pattern is why AIP-134 mandates explicit masks.

Worked example: PATCH with update_mask (safe)

With an explicit mask the server's behavior is specified at the call site, not inferred from body shape. New fields added to the schema later never affect existing calls.

# Explicit mask — only title and due_time are written; everything else is untouched
PATCH /v1/projects/proj_42/tasks/task_77?update_mask=title,due_time HTTP/1.1
Content-Type: application/json

{
  "title": "Finalise API spec v2",
  "due_time": "2025-06-25T17:00:00Z"
}

HTTP/1.1 200 OK
{
  "name": "projects/proj_42/tasks/task_77",
  "title": "Finalise API spec v2",
  "notes": "See Notion doc for context",
  "status": "open",
  "due_time": "2025-06-25T17:00:00Z",
  "assignee": "users/ada",
  "labels": ["backend", "spec"],
  "update_time": "2025-06-18T15:01:00Z"
}

Now consider clearing due_time entirely — the client wants the task to have no deadline. With an implicit mask, sending "due_time": null is ambiguous. With an explicit mask it is unambiguous: the field is in the mask, the body value is null, so the server clears it.

# Clearing a field: due_time is in the mask; null in the body means "clear it"
PATCH /v1/projects/proj_42/tasks/task_77?update_mask=due_time HTTP/1.1
Content-Type: application/json

{
  "due_time": null
}

HTTP/1.1 200 OK
{
  "name": "projects/proj_42/tasks/task_77",
  "title": "Finalise API spec v2",
  "notes": "See Notion doc for context",
  "status": "open",
  "due_time": null,
  "assignee": "users/ada",
  "labels": ["backend", "spec"],
  "update_time": "2025-06-18T15:02:00Z"
}

Under the hood: how it actually works

The server-side algorithm for applying a field mask is straightforward to implement but worth making explicit, because the edge cases cluster around nested fields and null values.

  1. Parse update_mask — split the comma-separated string into a set of field paths. Validate each path against the resource schema (see below). If any path is invalid, return 400 Bad Request immediately — do not silently ignore unknown paths.
  2. Read the current resource from the datastore. This is a full read, not a projection — you need all fields so you can merge into them.
  3. For each path in the mask, locate the corresponding value in the request body. If the value is present (even if null), overwrite that field in the current resource with the request value. Null means clear; any other value means replace.
  4. Fields not in the mask are never touched, regardless of what appears in the body. A field can be in the body but not in the mask — the server ignores it. This prevents accidental overwrites from clients that include extra data.
  5. Write the merged resource back atomically (ideally with an optimistic lock check if you support ETags or If-Match).
  6. Return the full updated resource — not just the changed fields. The client gets the authoritative final state.

Nested field paths. A path like assignee.user_id means "reach into the assignee sub-object and update only the user_id field; leave all other assignee sub-fields untouched." A path like assignee (without a sub-path) covers the entire assignee object — all its sub-fields are replaced as a unit from whatever the body provides for assignee.

FieldMask origin. This pattern comes from Protocol Buffers, where google.protobuf.FieldMask is a first-class message type. The paths field holds an array of dot-delimited strings that map exactly to proto field names. AIP-134 projects this into REST by turning the FieldMask into the update_mask query parameter, comma-joined. The semantics are identical.

# Scenario 1: update title and due_time only $ curl -s -X PATCH \ "https://tasks.example.com/v1/projects/proj_42/tasks/task_77?update_mask=title,due_time" \ -H "Content-Type: application/json" \ -d '{"title":"Finalise API spec v2","due_time":"2025-06-25T17:00:00Z"}' | jq . { "name": "projects/proj_42/tasks/task_77", "title": "Finalise API spec v2", "notes": "See Notion doc for context", "status": "open", "due_time": "2025-06-25T17:00:00Z", "assignee": "users/ada", "labels": ["backend", "spec"], "update_time": "2025-06-18T15:01:00Z" } # Scenario 2: clear due_time by passing null with it in the mask $ curl -s -X PATCH \ "https://tasks.example.com/v1/projects/proj_42/tasks/task_77?update_mask=due_time" \ -H "Content-Type: application/json" \ -d '{"due_time":null}' | jq . { "name": "projects/proj_42/tasks/task_77", "title": "Finalise API spec v2", "notes": "See Notion doc for context", "status": "open", "due_time": null, "assignee": "users/ada", "labels": ["backend", "spec"], "update_time": "2025-06-18T15:02:00Z" } # Scenario 3: invalid path — server rejects immediately $ curl -s -X PATCH \ "https://tasks.example.com/v1/projects/proj_42/tasks/task_77?update_mask=title,ghost_field" \ -H "Content-Type: application/json" \ -d '{"title":"x"}' | jq . HTTP/1.1 400 Bad Request {"error":{"code":400,"message":"Invalid field in update_mask: ghost_field"}}

How to debug & inspect it

SymptomLikely causeFix
A field I did not touch got wiped after my update You used PUT instead of PATCH, or you sent a PATCH to an implicit-mask server and omitted the field Switch to PATCH with explicit update_mask; list only the fields you intend to change
I accidentally cleared a field I wanted to keep The field was in update_mask but the body had null (or the key was absent, and the server treats absent-in-mask as null) Audit your body construction; if you intend no change, remove the field from update_mask entirely
My update had no visible effect — field value did not change The field you wanted to update was not included in update_mask Add the field to update_mask; confirm it matches the server's schema field name exactly
Server returns 422 Unprocessable Entity or 400 mentioning update_mask A field path in update_mask does not exist in the resource schema — misspelling, wrong nesting, or using a computed field like create_time Check the field name against the API reference; note that read-only fields like create_time and update_time are never valid in a mask
Extra fields in the body were silently applied even though they were not in the mask Server is using an implicit mask (ignoring the explicit mask) — implementation bug File a bug; a compliant server must ignore body fields not mentioned in the mask
🎯 Interview angle

A common follow-on question after discussing PATCH: "How does field masking relate to API versioning and backward compatibility?" The connection is subtle but important. Explicit field masks are a form of forward compatibility: because the client declares exactly which fields it cares about, the server can add new fields to a resource without any risk that existing PATCH calls will accidentally clear them. An implicit-mask server, by contrast, turns every new field addition into a potential breakage vector — older clients omit the new field from their bodies, and the server may wipe it. This means field masks reduce the surface of changes that require a version bump, making your API more stable over time. Cross-reference versioning (rel-01) when this comes up: the principle is the same — be conservative in what you let clients accidentally overwrite.

⚠️ Common trap: implicit mask and new fields

If you implement implicit mask (infer the update set from whatever fields are present in the body), every new field you add to the resource schema is a latent breakage waiting to happen. A client compiled before the field existed will never include it in a PATCH body. Your implicit-mask server will treat that omission as "this field should be cleared." When you add labels to tasks tomorrow, every old client that does a PATCH will silently wipe labels it never knew about. Explicit mask sidesteps this entirely: the server only touches what the client explicitly names.

✅ Always validate field paths eagerly

When you receive an update_mask, validate every path against the resource schema before doing any reads or writes. Return a 400 Bad Request immediately if any path is unrecognised, refers to a read-only field (like create_time), or violates your schema constraints. Silently ignoring unrecognised paths is dangerous: the client believes it updated a field, the server did nothing, and the two sides now have inconsistent state with no error surfaced. Fail loudly and early.

🧠 Quick check

1. A client sends PATCH /v1/projects/proj_42/tasks/task_77 with body {"title": "New title"} and no update_mask. Under an implicit-mask server, what happens to the notes field?

An implicit-mask server infers its update set from the fields present in the body. Since notes is absent, it is not in the inferred mask and is left untouched. This is the "happy path" for implicit mask — but it breaks when a client that doesn't know about a newer field sends a PATCH, causing the server to wipe that field.

2. A client wants to clear (set to null) the due_time on a task. Using explicit update_mask, the correct request is:

To clear a field, it must appear in the mask (so the server knows to touch it) and its value in the body must be null (so the server knows to clear rather than update it). An empty body with the field in the mask is implementation-defined and not universally safe. A wildcard mask is not a standard AIP pattern and bypasses the safety the mask provides.

3. What is the main advantage of explicit update_mask over implicit mask (inferring from present fields)?

The explicit mask decouples "what I'm sending" from "what I want changed." A client can include extra context in the body and know the server will ignore it. More importantly, the mask resolves the null-vs-omitted ambiguity and protects against new schema fields being accidentally cleared by older clients.

4. A field path in update_mask refers to a sub-resource: update_mask=assignee. What does this mean for the assignee object's sub-fields?

A path that refers to a parent field covers all its descendants. update_mask=assignee means "replace the entire assignee object with what the body provides." If you want surgical control over individual sub-fields, use dot-notation paths like assignee.user_id.

✍️ Exercise: design the field mask API for a calendar event resource (try before opening)
Scenario

You are designing PATCH for a calendar event resource. Events have: title, description, start_time, end_time, location (an object with address and map_url), attendees (array of email strings), and read-only fields create_time, update_time, organizer. A client wants to: (a) update the title only; (b) clear the location; (c) replace the entire location object; (d) update only the map_url inside location. Write the update_mask value for each case and explain how the server should handle a request that includes create_time in the mask.

Model answer:

  1. (a) Update title only: ?update_mask=title with {"title": "New title"} in the body. All other fields are untouched.
  2. (b) Clear location: ?update_mask=location with {"location": null} in the body. The entire location object is cleared.
  3. (c) Replace entire location: ?update_mask=location with the full new location object in the body: {"location": {"address": "1 Infinite Loop", "map_url": "https://maps.example.com/..."}}. Both sub-fields are written.
  4. (d) Update only map_url: ?update_mask=location.map_url with {"location": {"map_url": "https://maps.example.com/new"}}. Only location.map_url changes; location.address is untouched.
  5. create_time in the mask: Return 400 Bad Request immediately with a clear error message: "Field 'create_time' is read-only and cannot appear in update_mask." Never silently ignore it — the client thinks it updated a field it did not.

Rubric: ✓ Correct mask for title-only (a) ✓ null in body for clearing (b) ✓ full object in body for replace (c) ✓ dot-notation for sub-field (d) ✓ 400 for read-only field in mask. Four out of five = strong answer; all five = excellent.

Key takeaways

Sources & further reading