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
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 userUser 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:
[
{"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:
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 resultEvery 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:
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 gonenginx 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):
-- 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:
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:
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.
Related
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 isINSERT ... ON CONFLICT DO NOTHING RETURNING, not a SELECT followed by an INSERT.
- 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:
- “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?” -
addto/tags/-appends again, andremove /tags/0removes the next element. Make it conditional:If-Matchwith the ETag you read, a leadingtestoperation in the patch itself, or merge patch where the change is an assignment.
- Follow-up: “Which JSON Patch operation breaks on replay, and how do you
make a PATCH safe to retry?” -
- “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-Matchwith the ETag you read, and resolve the resulting 412 with a GET, because your own first attempt may be what moved the version.
- 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