Two methods, two completely different contracts
The temptation is to file these as “big update” and “small update”. They are not two sizes of the same operation. They differ in what the request body means, and everything else follows from that.
PUT | PATCH | |
|---|---|---|
| Defined by | RFC 9110 §9.3.4 | RFC 5789 |
| The body is | A complete new representation of the resource | A patch document — a set of instructions |
| Effect on omitted fields | They are removed or reset to defaults | They are untouched |
| Idempotent | Yes, always | Not guaranteed; depends on the document |
| Safe | No | No |
| May create the resource | Yes, at a client-chosen URL | Only if the API says so |
Content-Type | The resource's own media type | The patch format's media type |
Read the last row twice, because it is the one that trips people. A PUT body carries
Content-Type: application/json because the thing you are sending is the JSON resource.
A PATCH body is not the resource at all — it is a description of edits — so its media type
names the patch language, not the resource. Sending a patch with the resource's media type is legal in the
sense that nothing explodes, but you have thrown away the only signal that tells the server how to interpret
the bytes.
RFC 9110 puts the PUT requirement plainly: the target resource's state is to be created or
replaced with the state defined by the enclosed representation. Replaced. RFC 5789 was written years later
precisely because there was no way to express a partial change, and it is explicit that PATCH
is neither safe nor idempotent. If you want the wider context on where these sit among the other verbs, the
HTTP methods reference covers all nine with their safety and cacheability
properties.
A worked example of what a partial PUT destroys
Abstract statements about “omitted fields” do not land until you watch a field vanish. Suppose the server
stores this at /v1/profiles/usr_8812:
{
"id": "usr_8812",
"displayName": "Ada Lovelace",
"email": "ada@example.com",
"timezone": "Europe/London",
"marketingOptIn": true
}
A settings screen lets the user change their display name and nothing else. The developer, reasoning that only two fields changed, sends only two fields:
PUT /v1/profiles/usr_8812 HTTP/1.1
Content-Type: application/json
{
"displayName": "Ada King",
"email": "ada@example.com"
}
A server that implements PUT correctly now stores exactly what it was given, plus whatever the
schema fills in for absent members:
{
"id": "usr_8812",
"displayName": "Ada King",
"email": "ada@example.com",
"timezone": null,
"marketingOptIn": false
}
The timezone is gone. The marketing preference has flipped to whatever the default happens to be — and if
that default is false, the user has been silently unsubscribed by a request that was supposed
to change a name. Nobody sent a delete. Nobody sent false. The absence was the
instruction. This is not a bug in the server; it is the server doing what PUT means.
The same change as a patch document touches one member and states nothing about the rest:
PATCH /v1/profiles/usr_8812 HTTP/1.1
Content-Type: application/merge-patch+json
{ "displayName": "Ada King" }
Afterwards, timezone is still Europe/London and marketingOptIn is
still true. If you are unsure which behaviour your own API implements, the quickest check is to
send both requests from the API tester and read the resource back between them. The correct
PUT for this edit is not smaller — it is the whole object
with one member altered, which means the client has to have fetched it first. That fetch is not free, and
as the concurrency section below explains, it opens a window in which somebody else's edit can be lost.
The three patch body formats
PATCH is deliberately a framework rather than a format. RFC 5789 defines the method and leaves
the patch language entirely to the media type. Three options dominate in practice: two standardised, one
merely popular.
JSON Merge Patch — RFC 7386
Media type application/merge-patch+json. The document looks like a fragment of the resource:
an object whose members name the fields you want to change. The server walks it recursively against the
stored document. Three rules define the whole format:
- A member present with a value sets that field.
- A member present with the value
nulldeletes that field. - A member absent from the patch leaves the stored field untouched.
Nesting is recursive: patching {"address":{"city":"Bath"}} changes only the city and leaves the
postcode and country inside address alone. Arrays are the exception, and it is a sharp one —
an array in a merge patch replaces the stored array wholesale. There is no element-wise
merge, no append, no “change item 3”. To add one tag you must send every tag you want to end up with.
{ "timezone": "Europe/Paris", "nickname": null, "tags": ["staff", "beta"] }
That patch sets the timezone, deletes the nickname member entirely, and replaces the tag list
with exactly those two entries regardless of what was there before. The consequence of the null
rule is worth stating baldly: merge patch cannot store a literal JSON null. The syntax is
spent on “delete”. If your schema distinguishes “absent” from “present but null” — and plenty of schemas do,
for tri-state flags or nullable foreign keys — merge patch simply cannot express one of those states, and
you need JSON Patch or a full PUT.
In exchange you get a format that is trivial to read, trivial to generate from a form, and idempotent in practice: every operation it can express is an assignment or a deletion, so applying it twice lands where applying it once did.
JSON Patch — RFC 6902
Media type application/json-patch+json. The document is not an object but an
array of operations, applied in order. Six operations exist:
| Operation | Required members | Effect |
|---|---|---|
add | path, value | Insert a member or array element; on an existing object member, sets it |
remove | path | Delete the member or element at the path |
replace | path, value | Set an existing location; fails if it does not exist |
move | from, path | Remove from one location and add at another |
copy | from, path | Duplicate a value to another location |
test | path, value | Assert a value; the whole patch fails if it does not match |
Locations are given as JSON Pointer strings, defined in RFC 6901. A pointer is a sequence
of /-separated reference tokens: /address/city descends two levels;
/tags/0 selects the first array element; an empty pointer refers to the whole document. Because
/ and ~ are structural, they are escaped inside a token — ~1 means a
literal / and ~0 means a literal ~. So a field genuinely named
a/b is addressed as /a~1b, and unescaping must expand ~1 before
~0 or you will corrupt tokens containing ~01. There is one extra token,
-, valid only in an add against an array, meaning “the position after the last
element” — this is how you append.
[
{ "op": "test", "path": "/timezone", "value": "Europe/London" },
{ "op": "replace", "path": "/timezone", "value": "Europe/Paris" },
{ "op": "add", "path": "/tags/-", "value": "vip" },
{ "op": "remove", "path": "/nickname" }
]
The test operation is the underrated one. It is an optimistic-concurrency primitive built into
the patch itself: assert the value you believe is stored, and if another writer changed it first, the whole
patch is rejected rather than applied over the top. That gives you field-level conditional writes without
needing an entity tag, and it composes — several test operations can guard a multi-field edit.
JSON Patch is atomic. RFC 6902 requires the operations to be applied as a single unit: if any one of them fails, none of them take effect and the resource is left exactly as it was. You never end up with the first two operations applied and the third not.
And it is not idempotent, which is not a flaw but a direct consequence of expressiveness.
Send {"op":"add","path":"/tags/-","value":"vip"} once and the array grows by one element. Send
the identical request again — because the connection dropped and your HTTP client retried — and it grows by
another. Two vip entries, from one user action. A move is worse: replayed, it
fails outright because the source path no longer exists.
A plain JSON object of changed fields
The third option is not standardised at all, and it is the most common thing you will meet in the wild:
Content-Type: application/json with a body containing just the members you want changed. It
behaves like merge patch for the simple cases and is what most web frameworks generate by default.
It works. The objection is not correctness but interoperability: the media type says “this is a JSON
document”, which tells the server nothing about how to interpret it, so the semantics live only in your
prose documentation. Does null delete the field or store a null? Does a nested object merge or
replace? Does an array append or overwrite? Merge patch answers all three in a specification; a plain JSON
body answers them in whatever the framework happened to implement.
Side by side
| Property | JSON Merge Patch | JSON Patch | Plain JSON object |
|---|---|---|---|
| Specification | RFC 7386 | RFC 6902 | None |
| Media type | application/merge-patch+json | application/json-patch+json | application/json |
| Document shape | Object mirroring the resource | Array of operations | Object mirroring the resource |
| Delete a field | Set it to null | remove operation | Undefined by convention |
| Store a literal null | Impossible | Yes, via replace | Ambiguous |
| Append to an array | Impossible — send the whole array | Yes, add to /path/- | Usually impossible |
| Nested objects | Merged recursively | Addressed by pointer | Framework-dependent, often shallow |
| Conditional application | No | Yes, test operation | No |
| Atomic | Naturally, single document | Required by the RFC | Implementation-defined |
| Idempotent | In practice, yes | No | Usually, by accident |
| Human-readable diff | Very | Verbose but precise | Very |
If either body is being rejected and you cannot see why, run it through the JSON formatter — a trailing comma inside an operations array is easy to miss and produces a 400 that says nothing useful.
Idempotency, defined properly
A method is idempotent when N identical requests leave the server in the same state as one.
That is the whole definition, and the emphasis belongs on server state. It is not a claim that the
responses will match. A PUT that creates on the first call returns
201 and then 200 on the second;
the stored state is identical both times, so it is idempotent. Conversely an endpoint that returns a
byte-identical response every single time is still non-idempotent if it appends a row on each call.
PUT is idempotent because the client supplies the complete target state. “Make it look like
this” has the same outcome whether you say it once or ten times. PATCH is idempotent only when
the specific patch document happens to describe absolute values rather than relative changes:
| Patch document | Idempotent? | Why |
|---|---|---|
{"status":"archived"} (merge patch) | Yes | Assigns a fixed value; the second application changes nothing |
[{"op":"replace","path":"/status","value":"archived"}] | Yes | Also an absolute assignment |
[{"op":"add","path":"/tags/-","value":"vip"}] | No | Appends again on every replay |
A custom {"loginCount":{"increment":1}} | No | Relative to the current value |
[{"op":"remove","path":"/nickname"}] | Yes | Second removal is a no-op or an error, but state matches |
[{"op":"move","from":"/a","path":"/b"}] | No | Replay fails; the source is gone |
This matters for exactly one practical reason: what a client does when a write times out.
A timeout tells you nothing about whether the server processed the request. The response may have been lost
on the way back. If the method is idempotent, the client can simply send it again and the worst case is
wasted work. If it is not, a blind retry may append a duplicate tag, double an increment or, in the worst
designs, charge somebody twice. HTTP client libraries, service meshes and load balancers know this and
retry idempotent methods automatically — so a non-idempotent PATCH can be replayed by
infrastructure you did not even write.
When you genuinely need a non-idempotent operation to be retry-safe, bolt idempotency on at the application
layer with an Idempotency-Key header. The client generates a unique key per logical operation —
a UUID is the usual choice — and the server records the key with the
result. A repeat arriving with a key it has already seen returns the stored outcome instead of applying the
patch again. It is a de facto convention rather than a standard, but it is the pattern every serious
payments API uses, and it is the right answer for an appending PATCH too.
Concurrency: whose write wins
Because PUT needs the complete representation, the client that only has one field to change
must first fetch the resource, alter its copy and send the whole thing back. That read-modify-write cycle
has a gap, and anything another client writes inside the gap is destroyed:
Client A: GET /v1/profiles/usr_8812 → timezone "Europe/London", tier "free" Client B: PATCH /v1/profiles/usr_8812 → tier becomes "pro" Client A: PUT /v1/profiles/usr_8812 → sends its whole copy, tier "free" Stored result: tier "free". B's upgrade is gone, and nobody got an error.
PATCH narrows this window but does not close it. A patch that only mentions
timezone cannot clobber tier, so unrelated fields are safe. Two clients patching
the same field still race, and a patch built from a stale read — “set the total to 90 because I saw
100 and applied a discount” — is just as lossy as a full replacement.
The real fix is a precondition. Take the ETag from your GET, send it back on the
write as If-Match, and the server compares before applying:
GET /v1/profiles/usr_8812 HTTP/1.1
→ 200 OK
ETag: "v41"
PATCH /v1/profiles/usr_8812 HTTP/1.1
Content-Type: application/merge-patch+json
If-Match: "v41"
{ "timezone": "Europe/Paris" }
If the stored entity tag is still "v41", the patch applies. If somebody wrote in between and it
is now "v42", the server rejects the request with
412 Precondition Failed and the client re-reads, rebases its change
and retries. A lost update has become a visible error, which is the entire point. A server that refuses to
accept unconditional writes at all should say so with
428 Precondition Required rather than silently applying them — that
status exists precisely to tell a client “resend this with If-Match”.
With JSON Patch you have a second, finer-grained option: a leading test operation on just the
field you are changing. If-Match guards the whole resource, so any concurrent edit anywhere in
the document causes a conflict; a test guards one path, so unrelated edits pass through. Which
you want depends on whether your invariants span fields. ETag and the conditional request
headers are covered further in the HTTP headers guide.
Status codes, and advertising your patch formats
Both methods share most of their response vocabulary, but a few codes are specific enough to get wrong.
| Code | When | Applies to |
|---|---|---|
| 200 OK | Write applied and you are returning the new representation | Both |
| 204 No Content | Write applied, nothing to send back | Both |
| 201 Created | PUT created a resource at a client-chosen URL | Mostly PUT |
| 202 Accepted | The change is queued and will be applied later | Both |
| 400 Bad Request | The patch document is malformed — invalid JSON, missing op | Mostly PATCH |
| 409 Conflict | The change conflicts with the current state of the resource | Both |
| 412 Precondition Failed | If-Match did not match the current entity tag | Both |
| 415 Unsupported Media Type | You sent a patch format the server does not implement | PATCH |
| 422 Unprocessable Content | The document is valid but cannot be applied — a test failed, a pointer targets nothing, the result breaks a business rule | Mostly PATCH |
| 428 Precondition Required | The server insists on conditional writes and you sent none | Both |
The 415 versus 400 versus 422 distinction is the one worth
internalising. 415 means “I do not speak this patch language at all”. 400 means
“I speak it, but that is not valid grammar”. 422 means “that is a perfectly good sentence and
I still cannot do what it says”. Three genuinely different client fixes: change the media type, fix the
syntax, or change the request.
RFC 5789 also defines a response header for discovery. Accept-Patch lists the patch media types
a resource understands, and it is appropriate on a 415, on an OPTIONS response, or
on any response from a patchable resource:
Accept-Patch: application/merge-patch+json, application/json-patch+json
Almost nobody sends it, which is a shame — it turns “read the documentation and guess” into a
machine-readable answer. If you are building an API that accepts PATCH, send it. And whether or
not the server does, the presence of PATCH in the Allow header is what tells you
the method is supported at all; a resource that does not accept it should answer
405 Method Not Allowed.
Choosing between them, by scenario
The general rule is a question about ownership: does the client hold the complete, current state of the
resource? If it genuinely does, PUT is honest and gives you free idempotent retries. If it holds
a fragment, describing the fragment is more truthful than reconstructing a whole document around it. Some
concrete cases:
| Scenario | Method | Reasoning |
|---|---|---|
| Syncing a config file from a repo to an API | PUT | The file is the desired state; replacement is the correct semantic, and re-running the sync is harmless |
| A mobile app toggling one notification setting offline | PATCH | The device may hold a stale copy of everything else; sending it back would resurrect old values |
| Bulk migration setting one field across many records | PATCH | Merge patch per record; no need to read each document first, so the job is far cheaper |
| Appending an item to an array | PATCH, JSON Patch | Only add to /path/- expresses append; guard the retry with an idempotency key |
| Clearing a field back to empty | PUT, or merge patch with null | Omitting from a PUT clears it; merge patch needs an explicit null |
Storing a genuine JSON null | PUT or JSON Patch | Merge patch reads null as “delete” and cannot express it |
| Client generates the identifier (a slug, a UUID) | PUT | The client knows the URL, so it can create-or-replace there without a separate creation call |
| Two users editing the same record | Either, with If-Match | Without a precondition, whoever writes last silently wins |
| Replacing an entire nested object, not merging it | PUT | Merge patch would recurse into it and keep members you meant to drop |
Common mistakes
Sending a partial body with PUT
The mistake at the top of this page, and the one that costs real data. It survives for years because most frameworks are lenient and merge instead of replacing, so the code appears correct — until the API moves behind a strict gateway, or a library upgrade tightens the behaviour, and suddenly every save wipes the fields the form does not show.
Treating PATCH as a smaller PUT
They are different languages, not different sizes. If you find yourself sending a complete resource
representation with the PATCH method, you have written a PUT with the wrong verb
on it — and you have lost the guarantee of idempotency for nothing in return.
Reaching for merge patch when you need to append
Merge patch replaces arrays outright. Teams discover this after shipping, when a patch meant to add one tag
deletes the other four. The fixes are: send the full array you want to end up with, or switch that endpoint
to JSON Patch, or expose a sub-resource so the append becomes
POST /v1/profiles/usr_8812/tags — often the cleanest answer of the three.
Assuming your framework does a deep merge
Plenty of PATCH implementations do a shallow merge: a nested object in the
request replaces the stored nested object entirely rather than merging into it. Patch
{"preferences":{"theme":"dark"}} against a preferences object holding four settings and you may
find the other three deleted. Test this against your actual server before relying on either behaviour — send
the request from the REST API tester, then GET the resource back
and look at what survived.
Forgetting that PUT is how you clear a field
Because PATCH ignores absences, there is no way to say “remove this” by leaving it out. Merge
patch needs an explicit null, JSON Patch needs a remove operation, and a plain JSON
body may have no vocabulary for it at all. If your API only offers PATCH with a plain body,
clearing an optional field can be genuinely impossible.
Returning 200 with the request echoed back
If you return a body from a write, return the resource as it now stands after the change — not the patch document, and not the representation the client sent. Clients use that body to refresh local state, and echoing the input hides any normalisation, server-set timestamp or recalculated field the write produced.
Try both against a live endpoint
Nothing settles this faster than watching two requests come back. Open the
API tester and send PUT https://httpbin.org/put with a small JSON body, then
the same body as PATCH https://httpbin.org/patch. The service echoes what it received —
method, headers and parsed body — so you can confirm your client is sending the
Content-Type you think it is, that the patch media type survived, and that the body arrived
intact rather than being re-encoded as form data. Change the type to
application/merge-patch+json and send it again to see the header pass through untouched.
From there, the HTTP methods reference puts these two in context with the
rest of the verbs, what a REST API is covers the resource modelling
that decides whether you need PATCH at all, and the
HTTP status code reference has every code the table above mentions.
Frequently asked questions
Does PUT really delete fields I leave out of the body?
On a spec-compliant server, yes. The body is a complete new representation, so a member absent from it is absent from the stored state afterwards — removed, or reset to its schema default. Many frameworks merge instead, which is convenient until the day one does not.
Is PATCH idempotent?
Not by definition. RFC 5789 defines it as neither safe nor idempotent, because a patch document may express a relative change. A merge patch that assigns fixed values happens to be idempotent; a JSON Patch appending to an array is not.
What Content-Type should a PATCH request use?
Whichever format the server documents — application/merge-patch+json,
application/json-patch+json, or a plain application/json object of changed fields.
An unsupported format should come back as 415.
How do I set a JSON field to null with a merge patch?
You cannot — null is the delete instruction, so the value itself is unreachable. Use JSON
Patch with a replace, or a full PUT.
Which status code means the patch format is not supported?
415 Unsupported Media Type, ideally with an
Accept-Patch header listing what the server does understand. Use
400 for malformed and
422 for valid-but-inapplicable.
Can PUT create a resource that does not exist yet?
Yes, when the client chooses the URL — the server answers 201 on
creation and 200 or 204 when it replaced something. PATCH creating a
resource is unusual and should be documented explicitly.