HTTPputpatchpartial updatenullmerge

PUT vs PATCH

PUT replaces the whole representation and is idempotent by construction; PATCH applies a partial change and is only as safe as your merge rules. The hard part is not choosing between them — it is saying what null means, and what absent means.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When a client updates this resource, is it stating the complete desired state (PUT) or requesting a delta (PATCH) — and does the contract define what null and absent each mean?
Consumers
Clients editing resources: a settings form that holds the full object and writes it back, a mobile app toggling one flag over a metered connection, a sync engine reconciling offline edits — each with a different natural update shape.
The promise
Updates have defined semantics for every field state — present, absent, and explicitly null — so what the client sends is exactly what the resource becomes, with no fields silently erased or accidentally kept.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

Replace vs modify: two different statements

PUT says: "here is the complete desired state of this resource — make it so." Idempotency follows from the shape itself: sending the same full state twice lands on the same full state, which is why retry middleware resends PUTs without ceremony (see HTTP Methods Are Promises). The price is that the client must *hold* the full state: any field it omits is a field it is asking to erase — omission is a statement under PUT, and the most common PUT bug is a client that fetched an old representation, edited one field, and PUT back a stale everything-else.

PATCH says: "apply this change." The client sends only what moves — ideal for the mobile toggle (12 bytes instead of 4KB) and for reducing mid-air collisions between editors of *different* fields. The price is semantic: a delta needs merge rules. And PATCH is not idempotent by construction — a merge-style patch ({"name": "x"}) happens to be replayable, but an increment-style or array-append patch is not; if clients will retry PATCHes, the contract must either restrict patches to replayable shapes or add Idempotency Keys: The Mechanism.

Neither is the "correct" update method. PUT fits resources clients naturally hold whole (a settings document, a DNS record, a config blob). PATCH fits large resources edited in small strokes, concurrent multi-editor scenarios, and constrained networks. Many strong APIs offer only PATCH; some offer both with distinct semantics; offering both with *identical* semantics is a sign nobody decided.

PUT misused as a partial update: omission erases
1GET /users/42
2→ { "name": "Ada", "email": "ada@ex.com",
3 "phone": "+44…", "avatar_url": "https://…" }
4
5# client wants to change only the name:
6PUT /users/42
7{ "name": "Ada Lovelace", "email": "ada@ex.com" }
8200 OK
9
10# phone and avatar_url: silently erased.
11# Under PUT, "I did not mention it" means "delete it".
Each method doing what its shape means
1# partial intent → PATCH: absent fields are untouched
2PATCH /users/42
3{ "name": "Ada Lovelace" }
4200 OK # phone, avatar_url intact
5
6# full-state intent → PUT: client holds the whole document
7PUT /configs/checkout
8If-Match: "v14"
9{ …the complete config, every field… }
10200 OK # idempotent: safe to resend on timeout

The bad example is not a broken server — it is a correct PUT serving a client with PATCH-shaped intent. Matching the method to the intent (delta vs whole) is what prevents the silent-erasure class of bugs entirely.

Null vs absent: the question PATCH cannot dodge

A merge patch has three field states, and a correct contract distinguishes all three: field present with a value → set it; field explicitly null → clear it; field absent → leave it alone. This is the JSON Merge Patch (RFC 7396) convention and it should be stated in your docs even if you never cite the RFC — because the default behavior of most frameworks' deserializers is to *collapse null and absent into one*, and whichever meaning your implementation accidentally picks, some client assumed the other (see Request Contracts: Required, Optional, Null and Absent for the same distinction on create).

The collapse has a concrete failure in typed clients: a Go struct or Java POJO deserializes {"phone": null} and {} into the identical zero-valued object, so the server cannot tell "clear my phone number" from "I said nothing about phone". Teams discover this when a mobile release starts erasing every field it did not mention — or the inverse, when users cannot clear a value at all because null is being dropped. The fixes are mechanical but must be chosen: presence-tracking deserialization (Optional<T>-with-presence, pointer fields plus raw-key inspection), or an explicit sentinel protocol, or JSON Patch (RFC 6902) — an operations list ([{"op": "remove", "path": "/phone"}]) that trades readability for total precision, including array edits that merge semantics handle badly.

What each field state must mean in a merge patch
Client sendsMeaningClassic implementation bug
{"phone": "+44…"}Set phone to this value
{"phone": null}Clear phoneDeserializer drops nulls → clearing is silently impossible
{} (phone absent)Do not touch phoneZero-valued struct field → absent treated as "set to empty" and data is erased
{"tags": ["a"]}Replace the whole array (merge patch cannot append)Server "helpfully" appends → replay doubles the tags and PATCH stops being replayable

Concurrency: both methods lose updates without help

PUT's read-modify-write cycle is a textbook lost-update setup: A GETs v1, B GETs v1, A PUTs, B PUTs — A's change is gone, no error anywhere (the anatomy is in The Lost Update, Step by Step). PATCH narrows the window but does not close it: two PATCHes to *different* fields merge fine, but two PATCHes touching the same field still race, and read-then-patch logic ("set status to approved because I saw it pending") races regardless of payload shape.

The contract-level fix is the same for both: preconditions. The client echoes the version it acted on — If-Match: "v14" with an ETag — and the server refuses with 412 Precondition Failed when the resource has moved (mechanics in Conditional Requests: ETags, 304 and 412, strategy in Optimistic Concurrency: Versions and If-Match). Whether to *require* If-Match on writes is a real design decision: requiring it makes lost updates structurally impossible and every client slightly more complex; making it optional means the clients that least understand races are the ones running unprotected. For resources where overwrites destroy human work or money state, require it.

One more retry note ties the module together: a PUT resent on timeout is safe by construction; a PATCH resent on timeout is safe only if the patch is replayable (merge-style, no increments) — one more reason to constrain PATCH shapes in the contract rather than accept arbitrary deltas.

Key points

  • PUT states complete desired state (omission = erasure, idempotent by construction); PATCH states a delta (absent = untouched, idempotency depends on your merge rules).
  • Match the method to client intent: full-document holders use PUT; field-level editors and constrained networks use PATCH.
  • A merge patch has three field states — value, explicit null, absent — and the contract must define all three before a framework default defines them for you.
  • Typed deserializers collapse null and absent; without presence tracking, clearing a field and saying nothing become indistinguishable.
  • Both methods lose updates under concurrency; If-Match preconditions (412 on staleness) are the contract-level fix, required where overwrites are expensive.
  • Keep PATCH shapes replayable (no increments/appends) or add idempotency keys, so timeout retries stay safe.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: implements PUT but documents it as "update"; clients naturally send only changed fields.
  2. 2
    Client → API: PUTs {"name": …} to rename a user; every unmentioned field resets to defaults.
  3. 3
    Users → support: profiles keep "losing" phone numbers and avatars; the bug reproduces only for clients that edit partially.
  4. 4
    Team → API: switches to PATCH but the deserializer drops nulls; now users cannot *clear* any optional field.
  5. 5
    Two clients → one resource: read-modify-write races overwrite each other's edits with no 4xx anywhere; the data loss is discovered by the customer, not the monitor.
What breaks
  • Silent field erasure or un-clearable fields — data corruption that produces support tickets instead of error logs.
  • Lost updates between concurrent editors, invisible to both writers and to monitoring until a human notices missing work.
  • Retried non-replayable PATCHes (increments, appends) double-apply, corrupting counters and arrays.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Choose the update model per resource deliberately: PUT for whole-document resources, PATCH (merge semantics stated) for field-level editing — and document what absent and null each mean.
  • • Implement presence-aware deserialization so null-vs-absent survives the type system; test the three field states explicitly.
  • • Support If-Match/ETag on writes, and require it where lost updates destroy valuable state (see [[optimistic-concurrency]]).
  • • Constrain PATCH payloads to replayable shapes, or require idempotency keys for non-replayable operations.
Observe in production
  • • Field-level audit diffs showing values reverting to defaults right after writes are the erasure bug's signature.
  • • Track 412 rates: zero 412s on a multi-editor resource with If-Match optional means nobody is using preconditions — the races are just winning silently.
  • • Support tickets about "can't remove my phone number / bio / avatar" indicate nulls being dropped in deserialization.
Evolve without breaking
  • • Adding PATCH beside an existing PUT is additive; narrowing PUT's semantics later is breaking — decide the update model before consumers ship.
  • • New optional fields make old PUT clients dangerous (they omit what they do not know, erasing it); mitigate by evolving whole-document resources via PATCH-first or versioned representations (see [[backward-compatibility]]).
  • • Tightening concurrency (optional If-Match → required) is a breaking change worth an announced migration window on high-value resources.
What it costs
  • • PUT's simplicity taxes payload size and forbids partial intent; PATCH's efficiency taxes the contract with merge rules and presence-aware plumbing.
  • • Required If-Match adds a read (to get the ETag) and a retry loop (on 412) to every client write path — correctness bought with client complexity.
  • • JSON Patch (operation lists) buys precision for arrays and removals at a readability and tooling cost most teams only pay when merge semantics have failed them.

Misconceptions

Claim
“PATCH is just a lighter PUT.”
Reality
They make different statements: PUT asserts complete state, PATCH requests a delta. Treating them as interchangeable is exactly what produces the two signature bugs — omission-erasure under PUT-as-update, and null-dropping under PATCH-without-merge-rules.
Claim
“PATCH is not idempotent, so it is unsafe to retry — the RFC says so.”
Reality
The RFC says PATCH is not *guaranteed* idempotent. A merge-style patch setting fields to values is perfectly replayable; an increment is not. Your contract decides which shapes you accept, and therefore whether retries are safe — that is a choice, not a prohibition.
Claim
“We do not need If-Match — our writes are fast, collisions are unlikely.”
Reality
The window is not the write duration; it is the read-to-write gap — the minutes a user has an edit form open, the hours a mobile client is offline. Human editing workflows have enormous windows, and each collision silently destroys someone's work.

Apply it