Skip to content
datahacks.dev
JSON

Partial Updates and Patch Semantics in JSON APIs Done Right

Why a PUT drops fields, why dict.update clobbers nested objects, how absent differs from null in Pydantic, and how field-level SQL avoids lost updates.

7 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

A client wants to change one thing about a record. The API has three ways to let it: send the whole record back with the change in it, send only the change, or send instructions. Each fails in a different way, and most of the failures happen in the handler, not in the format.

I built a small profile record and tried each approach with the mistakes that I see in real code. The last section is about the part that has nothing to do with JSON: two people changing the same record at once.

PUT means replace

Python
put(stored, {"name": "Ana B"})
# {'name': 'Ana B'}

The record lost four fields. This is what PUT means in HTTP: the body is the new state of the resource. A client that sends only the changed field to a PUT endpoint has not made a mistake in the protocol; it has used the wrong verb. Servers can make it worse by being lenient: a handler that fills missing fields with defaults will quietly reset bio and website to empty, and nothing in the response says so.

Use PUT when the client really has the whole record (a form that submits every field). It is simple and idempotent: sending it twice gives the same result. It is a poor fit for a mobile client with a stale copy, which will write old values back over newer ones.

dict.update is shallow

The most common implementation of PATCH in a quick service takes the body and updates the stored dictionary:

Python
s = copy.deepcopy(stored)
s.update({"settings": {"lang": "fr"}})
s["settings"]    # {'lang': 'fr'}

theme is gone. update replaces each top-level key whole, the same flaw as PostgreSQL's || operator on a nested object (storing user settings as JSON). It also stores explicit nulls: update({"bio": None}) left bio as None, so the key exists with a null value, which is a different state from absent.

Merge patch: simple, with two sharp edges

JSON Merge Patch (RFC 7386) fixes the nested case. Objects merge recursively; null deletes; everything else, including arrays, replaces. The implementation is a dozen lines:

Python
def merge_patch(target, patch):
    if not isinstance(patch, dict): return patch
    if not isinstance(target, dict): target = {}
    t = dict(target)
    for k, v in patch.items():
        if v is None: t.pop(k, None)
        else: t[k] = merge_patch(t.get(k), v)
    return t

merge_patch(stored, {"settings": {"lang": "fr"}, "bio": None, "tags": ["db"]})
# {"name": "Ana", "website": "https://ana.dev", "tags": ["db"],
#  "settings": {"theme": "dark", "lang": "fr"}}

theme survived and lang changed. bio was deleted by the null. tags was replaced whole, so a client that wanted to add a tag must send the full list. These are the edges:

  1. There is no way to store a null. A patch of {"website": null} removed the key: "website" in result was False. If your documents use null as a real value, merge patch cannot express it.

  2. Arrays are replaced, not merged. That is predictable and it makes concurrent edits of a list lose changes.

If either edge matters, use JSON Patch, where each change is an explicit operation (add, remove, replace) at a path, and arrays can be edited by position or with a test guard (applying a JSON Patch in Python and JavaScript).

Absent, null and empty are three answers

Whatever format you choose, the handler must keep three states apart: the client did not mention the field, the client said "no value", and the client said an empty value. A typed model collapses them unless it is told not to. With Pydantic, all fields default to None:

Python
class Profile(BaseModel):
    name: Optional[str] = None
    bio: Optional[str] = None
    website: Optional[str] = None

The model records which fields arrived:

Text
{"name":"Ana B"}               fields_set=['name']          dump_unset={'name': 'Ana B'}
{"bio":null}                   fields_set=['bio']           dump_unset={'bio': None}
{"name":"Ana B","bio":""}      fields_set=['bio', 'name']   dump_unset={'name': 'Ana B', 'bio': ''}
{}                             fields_set=[]                dump_unset={}

model_fields_set separated all four bodies. Now the bug, which I see often. A handler applies the model to the stored record with model_dump():

Python
s.update(model.model_dump())            # bio -> None, website -> None
s.update(model.model_dump(exclude_unset=True))   # bio, website unchanged

After the first line, a patch that mentioned only name had set bio and website to None: the defaults were applied as if the client had sent them. The second line applied only what arrived, and left Writes SQL and the website alone. exclude_unset=True is the one flag that makes a model-based PATCH correct. Without it, every partial update is a partial deletion.

When two clients write at once

The semantics above assume one writer. With two, a PATCH that is correct can still lose data if the server implements it as read, merge in code, write the whole document back. I ran the pattern with two clients, A changing the name and B changing the bio, both starting from the same record:

SQL
update d_profiles set doc = '{"name":"Ana A","bio":"Writes SQL",...}' where id=1;   -- A's whole document
update d_profiles set doc = '{"name":"Ana","bio":"Teaches SQL",...}' where id=1;    -- B's whole document
select doc->>'name', doc->>'bio';     -- Ana | Teaches SQL

A's name change was gone. Each client wrote a complete copy built from the state it read, and the last write won. Field-level updates avoid this when they touch different keys:

SQL
update d_profiles set doc = jsonb_set(doc, '{name}', '"Ana A"') where id = 1;
update d_profiles set doc = jsonb_set(doc, '{bio}',  '"Teaches SQL"') where id = 1;
select doc->>'name', doc->>'bio';     -- Ana A | Teaches SQL

Both changes survived, because each statement read the current document inside the update and changed one path. When two clients change the same field, one has to lose, and the question is whether the loser finds out. A version column and a conditional update tell it:

SQL
update d_profiles set doc = jsonb_set(doc,'{name}','"X"'), version = version + 1 where id = 1 and version = 1;   -- 1 row
update d_profiles set doc = jsonb_set(doc,'{name}','"Y"'), version = version + 1 where id = 1 and version = 1;   -- 0 rows

The second statement matched no row, so the application returns 409 Conflict or 412 Precondition Failed with the current state. HTTP has a header for this, If-Match with an ETag (RFC 9110 on conditional requests), and the format of the body does not matter.

Which to choose

Use PUT for forms and small records where the client has everything. Use merge patch when documents are plain objects, nulls never mean a value, and arrays are edited whole: most settings and profile endpoints qualify. Use JSON Patch for arrays edited by position, for documents where null is real, and where a test operation guards a change. Whatever you pick, keep a model that records what arrived, validate the result as well as the patch (validating request JSON before it reaches the database), and use a version or field-level SQL for concurrency. The merge patch specification is RFC 7386, JSON Patch is RFC 6902, and the Pydantic option is described in its documentation for model_dump. To see what a patch changed, the JSON diff tool compares two documents.

When a full replace is the right update

The client edits the whole thing. A form that shows every field and submits all of them is a replace. Trying to turn it into a patch adds code and no value.

The record is small and rarely contested. If one person owns each record, lost updates are theoretical. Add the version check if that changes.

You want idempotence above all. Retries of a PUT are safe. A patch that appends to an array or increments a counter is not, and one that replaces values is.

The resource is a file or a blob. There is nothing to merge. Replace it, and use an ETag to avoid overwriting someone else's version.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.