Backend / REST APIs / 04_put_vs_patch.md

PUT vs PATCH: which writes are safe to retry

Updated 4 interview angles 14 min read source
On this page10
  1. PUT replaces, so a repeat lands on the same state
  2. PATCH sends instructions, and the format decides
  3. Every request, twice
  4. The mechanism: assignments repeat, instructions accumulate
  5. Making a write safe to retry
  6. Who retries your request
  7. Where it breaks in production
  8. The trade not taken
  9. Related
  10. Interview angle

PUT vs PATCH: which writes are safe to retry

Article.

RFC 9110 defines idempotency in one sentence (section 9.2.2): a method is idempotent if “the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.” Each part of it rules something out:

  • The effect, not the response. A retried DELETE that already worked gets 404 where the first attempt got 204. The resource is gone either way, so DELETE is still idempotent - the repeat has the same effect “though the response might differ.”
  • The intended effect. A server may log each request or keep a revision history; those side effects are its own business, and it owns the damage when a retry repeats them.
  • Multiple identical requests. N copies are compared with one. A copy that lands after somebody else’s write is not covered, and that gap is where idempotent writes still lose data.

PUT, DELETE and the safe methods (GET, HEAD, OPTIONS, TRACE) are idempotent by definition; POST is not. PATCH “is neither safe nor idempotent” (RFC 5789), though it “can be issued in such a way as to be idempotent”.

The property matters at one moment: the connection dies before the response arrives, and the client cannot know whether the write happened. For an idempotent request, resending is correct either way, so client libraries and proxies resend without asking you. The full method table is in HTTP semantics and caching.

PUT replaces, so a repeat lands on the same state

PUT “requests that the state of the target resource be created or replaced with the state defined by the representation enclosed” (RFC 9110 section 9.3.4). The body is the whole new state, so the result depends on the body alone: sent once or five times, the resource ends up equal to it. That is why PUT can create without losing idempotency - it is create-or-replace at a URI the client chose, and the status code says which happened:

Status When
201 Created nothing existed and PUT created it (a MUST)
200 or 204 it existed and PUT replaced it
409 or 415 the body disagrees with the URI or its media type
use POST the server, not the client, picks the id

The last row is the RFC’s own: a service that picks the URI for the client “SHOULD be implemented using the POST method rather than PUT.” A retried create answers 201 then 200 - two responses, one effect.

Create-or-replace in FastAPI

python
from fastapi import FastAPI, Response
from pydantic import BaseModel

app = FastAPI()
db: dict[str, dict] = {}


class User(BaseModel):  # no defaults, so a partial PUT is a 422
    name: str
    email: str
    tags: list[str]


@app.put("/users/{user_id}")
def put_user(user_id: str, user: User, response: Response) -> User:
    if user_id not in db:
        response.status_code = 201  # created; a replay of this gets 200
    db[user_id] = user.model_dump()  # replace the whole state with the body
    return user

User has no defaults, so a partial PUT is a 422 rather than a silent reset of the missing fields - a PUT that quietly patches is the commonest way to break the method. And a PUT response may carry an ETag only if the body was stored unchanged (RFC 9110); a server that stamps updated_at into the document must leave the ETag off and let the client GET.

PATCH sends instructions, and the format decides

With PATCH, the body “contains a set of instructions describing how a resource currently residing on the origin server should be modified” (RFC 5789). Whether running the instructions twice equals running them once depends on the language they are written in - the patch format, named by Content-Type.

JSON Patch (RFC 6902): operations, some of them relative

application/json-patch+json is a list of add, remove, replace, move, copy and test operations aimed at JSON Pointers. The pointer decides idempotency more than the operation does:

Operation Replayed
replace /email same result
add /nickname an object member: replaced, same result
add /tags/- appends again: a duplicate
remove /tags/0 removes whatever is first now
remove /nickname same state, but an error: the path is gone
test changes nothing, or fails the whole patch

test is the one that rescues the rest. A PATCH is atomic - if any part fails, “the server MUST NOT apply any of the changes” - so a test in front of an append turns its replay into a rejected no-op:

json
[
  {"op": "test", "path": "/tags", "value": ["admin", "ops"]},
  {"op": "add", "path": "/tags/-", "value": "beta"}
]

The first run passes the test and appends; the replay finds ["admin", "ops", "beta"], fails the test, and changes nothing (409).

JSON Merge Patch (RFC 7396): a partial document

application/merge-patch+json sends the fields to change with their new values, and the algorithm is short enough to be its own definition:

python
def merge_patch(target, patch):
    """RFC 7396 section 2, without mutating the target."""
    if not isinstance(patch, dict):
        return patch  # arrays and scalars replace wholesale
    result = dict(target) if isinstance(target, dict) else {}
    for key, value in patch.items():
        if value is None:
            result.pop(key, None)  # null means delete
        else:
            result[key] = merge_patch(result.get(key), value)
    return result

Every key ends at a value taken from the patch itself - no index, no append, no arithmetic - so applying it to its own result changes nothing. Merge patch is idempotent by construction, and the price is in the RFC: null means delete, so explicit nulls cannot be stored, and arrays are replaced wholesale. Adding one tag means sending the whole list, built from a read that may already be stale.

Name the format

application/json on a PATCH leaves the server guessing whether {"tags": ["beta"]} is the new array or one more element. Accept the registered types and refuse the rest with 415 and an Accept-Patch header.

RFC 5789 supplies the other codes: 400 for a malformed patch, 409 when it cannot apply to the current state, 422 when it applies but leaves an invalid resource. Apply the patch to a copy and validate the result before storing it, so a patch that fails halfway leaves nothing behind.

Every request, twice

Sending each request a second time, as a retry would:

Request Status, then replay State after the replay
PUT full body 201, then 200 unchanged
add /tags/- 200, then 200 beta appended twice
remove /tags/0 200, then 200 two elements gone
test, then add 200, then 409 appended once
merge patch 200, then 200 unchanged
DELETE 204, then 404 gone either way

The add, remove and test rows are JSON Patch. A client retrying a DELETE should read 404 as success.

The mechanism: assignments repeat, instructions accumulate

An operation is idempotent when applying it to its own result changes nothing: f(f(s)) = f(s). That holds exactly when the new state is computed from the request alone. PUT computes it from the body, and so do merge patch and JSON Patch replace, one key at a time. add /tags/- also reads the array’s length, remove /tags/0 its order, count + 1 the count - so a repeat starts from a different state and does something different.

Assignment is what lets PUT and merge patch repeat, and it is also what makes them lose concurrent work: an assignment built from a stale read overwrites whatever happened since. Appends are the mirror image - concurrent appends both survive, a repeated one duplicates. Neither kind survives both a retry and a concurrent writer. A precondition does.

Idempotent is not replayable later

The definition compares f(f(s)) with f(s): a repeat straight after its original. A retry that lands after another client’s write is f(g(f(s))), which for an assignment is f(s) again - the other write silently undone:

text
Alice  PUT email=work      ──▶ applied; response lost
Bob    PATCH name=Lovelace ──▶ applied
Alice  PUT email=work      ──▶ applied again (retry)
       the whole old body wins: Bob's rename is gone

nginx re-sends a PUT to the next upstream after its 60-second read timeout - plenty of time for Bob. With If-Match carrying the ETag Alice read, her retry is refused with 412 instead, because the version moved.

Making a write safe to retry

Preconditions: If-Match and If-None-Match

If-Match applies a write only to the version the client read. The server “MUST NOT perform the requested method if the condition evaluates to false” and answers 412 (RFC 9110 section 13.1.1); comparison is strong, so W/"2" never matches. RFC 5789 asks for it whenever a patch format needs a known base point.

A 412 on a retry is ambiguous - another client changed the resource, or your own first attempt did - so resolve it with a GET. The server may instead answer 2xx when the requested change has evidently already been applied.

If-None-Match: * makes a PUT create-only: if anything exists, 412. It settles two clients racing to create the same URI. To stop clients skipping either header, answer unconditional writes with 428 Precondition Required (RFC 6585).

Do the check inside the write

Reading, comparing, then writing is a race in production: two requests both read version 1 and both pass. The comparison belongs in the statement that writes. For users (id uuid PRIMARY KEY, doc jsonb, version int DEFAULT 1):

sql
-- PUT: create or replace; an identical replay is a no-op
INSERT INTO users (id, doc) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET doc = EXCLUDED.doc, version = users.version + 1
    WHERE users.doc IS DISTINCT FROM EXCLUDED.doc
RETURNING old.id IS NULL AS created;   -- old/new aliases: PostgreSQL 18

-- PUT with If-None-Match: *  (no row back: answer 412)
INSERT INTO users (id, doc) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING
RETURNING version;

-- PUT or PATCH with If-Match: "3"  (no row back: answer 412)
UPDATE users SET doc = $2, version = version + 1
WHERE id = $1 AND version = $3
RETURNING version;

For the conditional statements, no row back means the precondition failed, decided atomically by the write itself. created tells 201 from 200 in one round trip; before PostgreSQL 18 that takes a second query. SQLAlchemy’s version_id_col does the same compare-and-set on every ORM flush (Transactions, Savepoints, Isolation).

POST has no state to compare against, and neither does an effect outside your database - a card charge, a sent message. Those need an Idempotency-Key (Idempotency keys).

Who retries your request

Retries of idempotent methods are already switched on, by somebody else’s default:

Retrier Re-sends When
urllib3 DELETE, GET, HEAD, OPTIONS, PUT, TRACE read error or timeout, 3x by default
urllib3 any method connect error: nothing was sent
requests nothing, until you mount a Retry then urllib3’s rules
httpx any method connect failures only, retries=n
nginx all but POST, LOCK, PATCH error or timeout, the default
TLS 0-RTT safe methods only RFC 8470; a server can answer 425

nginx has held back POST, LOCK and PATCH since 1.9.13, as RFC 9110 requires of a proxy, and re-sends PUT and DELETE to the next upstream after a timeout. TLS 0-RTT sets the bar higher, at safe: an attacker chooses when a replayed early-data request lands - exactly the late-retry case idempotency does not cover.

Where it breaks in production

Two confirmation emails from one PUT

A handler sets an order to confirmed and emails the customer, and one day it outlives the proxy’s read timeout. nginx re-sends the PUT to a second upstream: the write is idempotent, the email is not, and the customer gets two. A client that retries the resulting 504 makes it four. The signature is one access-log line whose $upstream_addr holds two servers; a client-side retry shows up as two lines.

Tie the email to the state transition, not to the request:

sql
WITH confirmed AS (
    UPDATE orders SET status = 'confirmed', version = version + 1
    WHERE id = $1 AND status <> 'confirmed'   -- only one request matches
    RETURNING id, version
)
INSERT INTO outbox (topic, payload)            -- same statement, same commit
SELECT 'order.confirmed', jsonb_build_object('order_id', id, 'version', version)
FROM confirmed;

Only the request that moves the order to confirmed writes the outbox row, which a relay publishes after commit (The transactional outbox).

The PATCH that cannot clear a field

A FastAPI PATCH usually validates against a model with every field optional, and the model_dump flag decides what an explicit null means:

python
from pydantic import BaseModel


class UserPatch(BaseModel):
    name: str | None = None
    nickname: str | None = None


patch = UserPatch.model_validate_json('{"nickname": null}')  # "clear my nickname"
print(patch.model_dump())                    # {'name': None, 'nickname': None}
print(patch.model_dump(exclude_none=True))   # {}
print(patch.model_dump(exclude_unset=True))  # {'nickname': None}

Plain model_dump() turns into SET name=NULL, nickname=NULL and wipes a field nobody mentioned. exclude_none=True leaves nothing to update, so the handler answers 200 for a change it never made. exclude_unset=True sends exactly what the client sent - merge patch semantics for a flat resource.

Every If-Match fails behind a compressing proxy

Since 1.7.3, nginx turns a strong ETag weak whenever it modifies a response, and gzip counts: the same reply arrives as ETag: "7" uncompressed and ETag: W/"7" gzipped. If-Match uses strong comparison, so a client that echoes the weak tag gets 412 on every write - in production only. Test conditional writes through the real proxy.

The trade not taken

Two ways to make a create retryable; this note leans on the first.

A client-chosen id with PUT. The client mints the id - uuid.uuid7() in Python 3.14, uuidv7() in PostgreSQL 18 - and creates with PUT /orders/{id}. The URI is the idempotency key: a retry is still one order, with no key table and no stored response. Version 7 because it is time-ordered, so new rows land at the right edge of the B-tree instead of scattering across it the way random v4 ids do.

It costs three things: the id reveals its creation time, the server must still check the caller owns the id before a replace, and a create-only retry gets 412, not the original 201.

POST with an Idempotency-Key wins when the server must choose the id, when the effect is not a resource you can PUT (a card charge), or when the client needs the first response replayed byte for byte. It costs a table, an in-progress state with a timeout, and a request hash to catch a reused key.

The rule: if the client can name the thing it is creating, let it, and use PUT. Reach for keys when it cannot.

Interview angle 4

  • “Can PUT create a resource?” - Yes. RFC 9110 defines PUT as “created or replaced” at the URI the client names, and it stays idempotent when it creates: the first PUT makes the resource equal to the body, the retry finds it already equal. If the server chooses the id, PUT is the wrong verb; that is POST, 201 and a Location.
    • Follow-up: “Which status codes tell create from replace, and what stops two clients both creating the same URI?” - 201 Created is a MUST on create; 200 or 204 on replace. Nothing stops the race unless the create is conditional: If-None-Match: * turns the second creator’s PUT into a 412, and on the server that is INSERT ... ON CONFLICT DO NOTHING RETURNING, not a SELECT followed by an INSERT.
  • “Why isn’t PATCH idempotent?” - Because its body is instructions run against the current state, so it depends on the patch format. Merge patch is idempotent by construction; JSON Patch mixes assignments with appends.
    • Follow-up: “Which JSON Patch operation breaks on replay, and how do you make a PATCH safe to retry?” - add to /tags/- appends again, and remove /tags/0 removes the next element. Make it conditional: If-Match with the ETag you read, a leading test operation in the patch itself, or merge patch where the change is an assignment.
  • “A customer got two confirmation emails from one PUT that timed out. What happened?” - The PUT was replayed, legitimately: nginx re-sends a PUT to the next upstream after its read timeout, and urllib3 retries a PUT after a read timeout by default. The write was idempotent; the email was not.
    • Follow-up: “How do you fix it?” - Send the email from the state transition: an UPDATE that matches only while the order is not yet confirmed, writing the outbox row in the same statement, so the replay changes nothing and sends nothing.
  • “A retried DELETE returned 404. Is DELETE not idempotent?” - It is. Idempotency is about the effect on the server, and the resource is gone either way. Treat a 404 on a retried DELETE as success.
    • Follow-up: “So any idempotent write is safe to retry?” - Only back to back. A PUT retry that lands after another client’s write reapplies the old state and silently reverts theirs. Send If-Match with the ETag you read, and resolve the resulting 412 with a GET, because your own first attempt may be what moved the version.