Web Development Bug Report

A mobile client sent a partial PUT and our API wiped every field it left out

After a mobile release, customers who changed their delivery phone number lost their saved address notes and opening hours. The app was sending a partial body with PUT, and the API did exactly what PUT means. The fix: PATCH with JSON Merge Patch, and a PUT that rejects partial bodies.

Amara Nwosu Amara Nwosu @amaranwosu · November 10, 2026 · 8 min read · 20 Views
Banner: partial PUT request wiping fields versus a PATCH merging changes

We run a delivery platform where business customers save locations: address, a phone number for the driver, opening hours and free-text notes like "use the loading dock on the north side". Two days after a mobile app release, support had a cluster of tickets with the same story. A customer edited the phone number on a location in the app, and afterwards the opening hours and notes for that location were gone.

The audit table made it concrete:

location_id | changed_at          | field          | old                      | new
------------+---------------------+----------------+--------------------------+------
L-20931     | 2026-10-21 08:14:52 | contact_phone  | +44 20 0000 0000         | +44 20 0000 0001
L-20931     | 2026-10-21 08:14:52 | opening_hours  | {"mon":"07:00-16:00",..} | NULL
L-20931     | 2026-10-21 08:14:52 | driver_notes   | use the loading dock ... | NULL

All three fields changed in the same request. Web users editing the same screen had no problem.

What the app sent

The API gateway logs request bodies for this endpoint with redaction. The new app version sent:

PUT /v2/locations/L-20931 HTTP/1.1
Content-Type: application/json

{"contact_phone": "+44 20 0000 0001"}

The old app version and the web client sent the full location every time, with only the phone number changed. The mobile team had optimised the new screen to send "only what changed" to save bandwidth on poor connections, which is a reasonable goal. They kept the method as PUT.

What the server did

Our handler for PUT built a complete location from the body:

@router.put("/v2/locations/{location_id}")
def replace_location(location_id: str, body: LocationIn, user=Depends(current_user)):
    location = repo.get_for_user(location_id, user)
    for field in LocationIn.model_fields:
        setattr(location, field, getattr(body, field))
    repo.save(location)
    return LocationOut.from_model(location)

LocationIn declared every field optional with a default of None, so validation accepted the partial body. Then the loop copied every field, including all the ones the client had not sent, as None.

What PUT means

HTTP is precise about this. RFC 9110 defines PUT as a request that the target resource's state be created or replaced with the state in the request body. A successful PUT of a representation means a later GET should return that representation. A PUT body is the whole resource, not a list of changes.

So the server was correct by the specification, and the client was wrong. But "correct by the spec" is not much comfort to a customer whose delivery notes vanished. The real problem was that our API accepted a body that could not have been a complete location and treated it as one.

What PATCH means

RFC 5789 defines PATCH as applying a set of changes to a resource. The body is a patch document whose media type says how to interpret it. The two common JSON formats are:

Format Media type Body shape Good for
JSON Merge Patch (RFC 7396) application/merge-patch+json a partial object; present keys are set, null removes simple objects, form edits
JSON Patch (RFC 6902) application/json-patch+json a list of operations (add, remove, replace, test...) arrays, conditional edits

Merge Patch matched what the mobile team wanted: send only the changed fields as a plain object.

Why validation did not catch it

Our input model was shared between create and replace, and somebody had made every field optional years ago to make a create endpoint more flexible. That decision quietly made PUT accept partial bodies. A model with required fields would have rejected the request with a 422, and the mobile team would have found the mismatch in their first test.

Environment

Item Value
API Python 3.12, FastAPI 0.115, Pydantic 2
Clients web app, iOS and Android apps, partner integrations
Endpoint PUT /v2/locations/{id}, about 9,000 updates per day
Affected 1,140 locations edited from app version 6.4.0 in 48 hours

1. Restore the data

The audit table had every old value, so we wrote a one-off script that, for each location changed by the affected app version, restored fields that were set to NULL in a request that only intended to change others. We ran it in dry-run mode first, had support spot-check twenty locations, then applied it.

2. Make PUT reject incomplete bodies

PUT now uses a dedicated model where every field of the resource is required. A field that can be empty is still required, but may be sent as null explicitly:

class LocationReplace(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str
    address: Address
    contact_phone: str | None
    opening_hours: OpeningHours | None
    driver_notes: str | None

The partial body from the app now gets a clear error instead of silent data loss:

{
  "detail": [
    {"type": "missing", "loc": ["body", "name"], "msg": "Field required"},
    {"type": "missing", "loc": ["body", "address"], "msg": "Field required"}
  ]
}

extra="forbid" also rejects unknown fields, which catches typos like driverNotes that would otherwise be ignored.

3. Add PATCH with JSON Merge Patch

For partial updates we added a PATCH endpoint that applies RFC 7396 semantics. Pydantic's model_fields_set tells us which keys the client actually sent, which is the distinction we were missing before: "not sent" versus "sent as null".

class LocationPatch(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str | None = None
    address: Address | None = None
    contact_phone: str | None = None
    opening_hours: OpeningHours | None = None
    driver_notes: str | None = None

@router.patch("/v2/locations/{location_id}")
def patch_location(location_id: str, request: Request, body: LocationPatch,
                   user=Depends(current_user)):
    if request.headers.get("content-type", "").split(";")[0] != "application/merge-patch+json":
        raise HTTPException(415, "Use Content-Type: application/merge-patch+json")

    location = repo.get_for_user(location_id, user)
    for field in body.model_fields_set:          # only keys present in the body
        setattr(location, field, getattr(body, field))
    repo.save(location)
    return LocationOut.from_model(location)

Behaviour, matching RFC 7396:

Request body Result
{"contact_phone": "+44 20 0000 0001"} phone changed, everything else untouched
{"driver_notes": null} notes cleared, everything else untouched
{} no change

Fields that must never be null, like name, get a separate validation check so {"name": null} returns 422 rather than clearing a required field.

For nested objects such as opening_hours, Merge Patch merges recursively. We implemented that for opening_hours so a client can change Monday without resending the week. Arrays are replaced as a whole in Merge Patch, which is one of its documented limits; for the one array field where that mattered we would have used JSON Patch, but none of our location fields needed it.

4. Guard against lost concurrent edits

A partial update reduces the chance of two editors overwriting each other, but does not remove it. Both endpoints now return an ETag and honour If-Match, returning 412 Precondition Failed when the location changed since the client read it. The mobile app sends If-Match on every edit.

1. Contract tests for both methods.

def test_put_rejects_partial_body(client, location):
    r = client.put(f"/v2/locations/{location.id}", json={"contact_phone": "+44 20 0000 0001"})
    assert r.status_code == 422

def test_patch_changes_only_sent_fields(client, location):
    r = client.patch(f"/v2/locations/{location.id}",
                     content='{"contact_phone": "+44 20 0000 0001"}',
                     headers={"Content-Type": "application/merge-patch+json"})
    assert r.status_code == 200
    after = client.get(f"/v2/locations/{location.id}").json()
    assert after["contact_phone"] == "+44 20 0000 0001"
    assert after["driver_notes"] == location.driver_notes
    assert after["opening_hours"] == location.opening_hours

def test_patch_null_clears_field(client, location):
    client.patch(f"/v2/locations/{location.id}", content='{"driver_notes": null}',
                 headers={"Content-Type": "application/merge-patch+json"})
    assert client.get(f"/v2/locations/{location.id}").json()["driver_notes"] is None

2. The OpenAPI document now shows two different request schemas, which the mobile team's generated client picked up. Their new release uses PATCH for edits; the previous release's partial PUTs now fail with 422, so we shipped the server change behind a short window where we logged instead of rejecting, until the fixed app version reached most users, then switched to rejecting.

3. Production monitoring. A dashboard tracks 422 responses on PUT by app version. It dropped to zero for the current version within a week.

4. Security implications. extra="forbid" closed a second risk: clients could previously send fields like owner_id that our loop would have copied onto the model if they had existed in LocationIn. Now only declared fields are accepted, and server-owned fields are not part of either input model.

Takeaways

  • PUT replaces the whole resource. A partial PUT body is a client bug, but the server should reject it, not obey it.
  • Use PATCH with a declared format (JSON Merge Patch for simple objects) for partial updates.
  • Distinguish "field not sent" from "field sent as null"; Pydantic's model_fields_set does this.
  • Use separate input models for create, replace and patch, and forbid unknown fields.

References: RFC 9110 section 9.3.4, PUT, RFC 5789, PATCH, RFC 7396, JSON Merge Patch, Pydantic BaseModel.

Add a label

#api-design #rest-api #http-methods #patch #json-merge-patch
Written by
Amara Nwosu @amaranwosu

I build and maintain public and partner APIs, currently for a logistics platform with web, mobile and third-party clients all calling the same endpoints. Before that I worked on identity: login, sessions, OAuth integrations and the long tail of edge cases they create. The bugs I care about sit at the contract between client and server. A PUT that wiped fields the client never sent, a cache that served one user's page to another, a field removal that broke app versions still in the wild, a refresh flow that logged people out when two tabs raced. None of these show up in a happy-path demo. My posts walk through how each one surfaced, what the HTTP or auth spec actually says, the fix we shipped and the tests we added so the next change to the API does not repeat it.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.