The question that actually separates them
Ask this before anything else: at the moment the request is sent, does the client already know the URL the resource will live at?
If it does not — because the server will mint a database key, a sequence number, a slug derived from the
content — then the client cannot address the resource, because the resource has no address yet. All it can
do is hand the data to something that does have an address, namely the collection, and ask it to
take custody. That request is a POST, and the server's job includes telling the client where
the result ended up.
If it does — because the client generated a UUID, or the identifier is
a natural key such as an ISBN, a country code or a filename — then there is no need for the intermediary.
The client can address the resource directly and state what it should contain. That request is a
PUT, and whether it created something or replaced something is an implementation detail from
the client's point of view. It asked for a state and got it.
So the two methods are not “create” and “update”. They are delegate the naming and declare the state at a name I already have. Once you see it that way, the rest of the differences stop looking like arbitrary protocol trivia.
POST | PUT | |
|---|---|---|
| Sent to | A collection: /v1/orders | A resource: /v1/orders/9f2c… |
| Identifier chosen by | The server | The client |
| Meaning of the body | Data for the collection to process | The complete desired state of that resource |
| Idempotent | No | Yes |
| Safe to retry blindly | Not without an idempotency key | Yes |
| Success status | 201 with Location | 201 on create, 200 or 204 on replace |
| Repeat the same request twice | Two resources | One resource |
The bottom row is the one to keep. It is not a subtlety; it is the difference between one order and two.
Creating with POST: the server names it
The collection is the resource being acted on. You are not addressing the order you are about to create, because it does not exist and has no URL; you are addressing the list and asking it to grow.
POST /v1/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
{ "sku": "TEA-004", "quantity": 2 }
→ 201 Created
Location: /v1/orders/ord_9f2c41
Content-Type: application/json
{ "id": "ord_9f2c41", "sku": "TEA-004", "quantity": 2, "status": "pending" }
Three things in that response deserve attention.
The Location header is the whole point of the exchange. It is how the server
answers the question the client could not answer itself. Omitting it — and plenty of APIs do — forces every
client to dig the identifier out of the body and reconstruct the URL by string concatenation, which means
every client now hard-codes your URL structure and breaks when you change it. Send the header.
The body is the created resource, not the request echoed back. It carries the fields the
server filled in: the identifier, a default status, timestamps, anything normalised. A client that reads
this body does not need a follow-up GET.
And the status is 201, not 200. If creation is
asynchronous — queued for a worker, awaiting approval — then 202
Accepted is the honest answer, ideally with a Location pointing at a status resource the
client can poll.
Note also what happens if you send that identical request again. Nothing stops it. The collection takes the data and adds another member, because that is what you asked for, and you now have two orders for the same tea. The server has done nothing wrong.
Creating with PUT: the client names it
Now suppose the client generates the identifier before it sends anything. It can address the resource directly, so there is no collection in the request at all:
PUT /v1/orders/018f3c7a-6b21-7e44-9d2e-5c1a0f8b7e33 HTTP/1.1
Host: api.example.com
Content-Type: application/json
{ "sku": "TEA-004", "quantity": 2 }
→ 201 Created
Send it again — same URL, same body — and the server replaces the state at that address with the same
state it already holds, then answers 200 or
204 because nothing new came into existence. One order, however
many times the request arrives. This is what people mean when they call PUT an
upsert: create if absent, replace if present, and the client does not have to know or care which
happened.
That property is worth a great deal in unreliable conditions. A mobile client that queues writes while offline and flushes them when the signal returns can replay its whole queue without deduplication logic. A sync job that copies records from one system to another can be re-run from the start after a crash. A retry policy in a service mesh can fire without anyone auditing whether it is safe.
What client-chosen identifiers cost
It is not free, and the trade-offs are worth stating before you adopt it everywhere.
- The client can pick a bad name. If identifiers are sequential integers, a client can guess or collide with somebody else's. Require an unguessable format — a UUID, ideally version 7 so the keys still sort by creation time — and validate the format server-side, rejecting anything else with 400.
- The server no longer owns its namespace. Compact sequential primary keys, custom slug schemes and identifier formats you might want to change later all become harder once clients supply them.
- You may need to forbid overwriting. A plain
PUTto an existing resource replaces it. If “create only” is what you mean, add the preconditionIf-None-Match: *, which tells the server to apply the request only when nothing exists at that URL and to answer 412 Precondition Failed otherwise. That turns the upsert back into a strict create without giving up idempotency, since replaying it still yields one resource and one outcome. - Not everything has a natural place to live. “Send an email”, “run a report”, “refund
this charge” do not describe a document at a knowable URL, and forcing them into one is worse than
using
POST.
Why POST cannot be idempotent
The property falls straight out of the target. Idempotence asks whether repeating a request leaves the server in the same state as sending it once — a statement about stored state, not about the responses, which is covered at length in the guide to idempotency.
For PUT, the request names one resource and supplies its entire contents. “Make the thing at
this address equal this” is an assignment, and assigning the same value twice is indistinguishable from
assigning it once. Nothing in the request refers to the current state, so the current state cannot
influence the outcome.
For POST, the request names the collection and supplies a member to add. “Add this” is an
accumulation, and accumulation is inherently sensitive to how many times it happens. There is no way to
make the generic form of POST idempotent, because the instruction genuinely means something
different the second time. You can bolt safety on top — the next section does exactly that — but the
method itself cannot carry the guarantee, which is why RFC 9110 does not claim it.
A related trap: this is also why HTTP infrastructure treats the two differently. Proxies, load balancers,
service meshes and client libraries will often retry an idempotent method automatically after a network
failure and will refuse to retry POST. Nobody on your team wrote that policy, and it is
correct — but it means a POST endpoint you have quietly made idempotent gets no benefit
unless clients know.
The duplicate submission problem
This is where the abstraction earns its keep, because it is the failure users actually see: two orders, two charges, two support tickets, from one intention.
The mechanism is almost always the same. The client sends a POST. The server receives it,
processes it fully, and starts writing the response. Something on the way back fails — a dropped
connection, a proxy timeout, a browser tab closed, a user who saw no spinner and clicked again. The client
has no response and therefore no information whatsoever about whether the operation happened. Its
two options are to retry, risking a duplicate, or to give up, risking a lost order. Neither is acceptable,
and you cannot pick correctly without more information than the protocol gives you.
Idempotency keys
The standard fix, and the one payment APIs settled on. The client generates a unique value for each logical operation and sends it in a header:
POST /v1/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 018f3c7a-6b21-7e44-9d2e-5c1a0f8b7e33
{ "sku": "TEA-004", "quantity": 2 }
The server records the key alongside the outcome. If a request arrives with a key it has already completed, it does not execute anything — it replays the stored response. The critical detail is that the key must be generated once per intention and reused on every retry of that intention. A client that generates a fresh key inside its retry loop has implemented an expensive way of changing nothing.
Uniqueness constraints
Sometimes the data already contains something that must not repeat: an invoice number, an email address, a customer's reference for the order. Enforce it in the database and answer a second attempt with 409 Conflict, ideally naming the conflicting field and linking the existing resource. This is cheaper than a key store and works even for clients that know nothing about idempotency headers — but it only helps where such a field genuinely exists.
POST/Redirect/GET, for browsers
For form submissions rendered by a server, the old pattern is still the right one: answer a successful
POST with 303 See Other and a Location
pointing at the result page. The browser then issues a GET, so the URL in the address bar is
safe to reload, bookmark or navigate back to. It does not protect against a double-click before the
response arrives, so it complements an idempotency key rather than replacing it.
Or move the creation to PUT
If the client can generate the identifier, the whole problem dissolves. There is no key store to maintain
and no retry policy to audit, because a replayed PUT is a no-op by construction. It is the
cleanest answer whenever the resource is a document rather than an event, and it is the reason
offline-first applications tend to use client-generated identifiers throughout.
POST is also the method for things that are not creation
PUT only ever means one thing. POST means “process this”, which is deliberately
broad — it is the method the specification uses for operations that do not fit any other verb.
- Actions on an existing resource.
POST /v1/orders/ord_9f2c41/refunds,POST /v1/articles/42/publish. Sent to a resource rather than a collection, this asks that resource to do something. Notice it still tends to create a record of the action, which is why modelling the action as a sub-collection reads naturally. - Searches with a body. A query too long or too structured for a URL is often sent as
POST /v1/search. It creates nothing and it is not cacheable, which is the real cost — see REST vs GraphQL for where that trade-off leads. - Non-idempotent submissions generally. Sending a message, triggering a build,
recording an event. There is no document being named, so there is nothing to
PUT. - Receiving webhooks. Providers deliver events by
POST, usually with at-least-once semantics, so your receiver must expect repeats — the webhook tester and the idempotency guide both cover deduplicating them.
Two habits to avoid. Do not tunnel reads through POST for convenience: you lose caching,
logging clarity and the safety guarantee that lets anything replay the request. And do not use
POST for a partial update because PUT felt too destructive — the method for
partial updates is PATCH, and PUT vs PATCH covers the patch
document formats, concurrency control and status codes that go with it.
Choosing, by scenario
| Scenario | Method | Why |
|---|---|---|
| Sign-up form creating a user with a database ID | POST /v1/users | The server owns the identifier; return it in Location |
| Uploading a file to a path the client already knows | PUT /v1/files/reports/q3.csv | The path is the identifier; re-uploading is harmless |
| Mobile app creating records while offline | PUT with a client UUID | The queue can be flushed and re-flushed without duplicates |
| Taking a payment | POST + Idempotency-Key | Server-assigned reference, but retries must never double-charge |
| Importing rows from a spreadsheet keyed by a natural ID | PUT per row | Re-running the import converges instead of duplicating |
| Creating strictly, never overwriting | PUT + If-None-Match: * | Idempotent create; an existing resource yields 412 |
| Refunding an order | POST /v1/orders/{id}/refunds | An action, expressed as a sub-collection that records it |
| Replacing a settings document wholesale | PUT | The client holds the complete desired state |
| Changing one field of a large record | PATCH | Neither method here fits; see PUT vs PATCH |
See it for yourself
The fastest way to internalise this is to watch a server report what it received. Open the
API tester, send POST https://httpbin.org/post with a small JSON body, then
send the identical body to https://httpbin.org/put with the PUT method. The echo
service shows you the method, the headers and the parsed body, so you can confirm your client sent the
Content-Type you expected and that the body survived intact
rather than being re-encoded as form data.
Then do the more instructive experiment against your own API: send the same creation request twice and
count the rows. If you get two, you have a POST endpoint and you now know exactly which
protection it needs. From here, the HTTP methods reference puts both
verbs in context with the other seven,
what a REST API is covers the resource modelling that decides
which collections exist in the first place, and the
status code reference has every code named above.
Frequently asked questions
Can both POST and PUT create a resource?
Yes, and that is why the comparison confuses people. They create in different places.
POST goes to a collection and asks it to add a member, leaving the server to mint the
identifier and report it in Location. PUT goes to the resource's own URL and
says “make the thing at this address look like this”, creating it if nothing is there. The difference is
who decided the address.
Why is POST not idempotent when PUT is?
Because a POST targets the collection, not the new member. Every POST to
/v1/orders is a fresh instruction to add an order, so ten identical ones produce ten orders. A
PUT names one resource and supplies its complete state, so ten identical ones leave exactly
one resource holding exactly that state.
Should the client or the server generate the ID?
Server-assigned is the safe default — the server owns its namespace and never trusts an outside identifier. Client-assigned is worth the cost when clients must create offline, when the same logical record arrives from several sources, or when you want retries to be naturally safe. If clients do choose, require an unguessable format such as a UUID.
What status code should a successful creation return?
201 Created with a Location header, and usually the
new representation in the body. Use 202 when creation is queued. A
PUT that replaced something existing returns 200 or
204 and no Location, because nothing new was
created.
How do I stop a retried POST creating a duplicate?
Send an Idempotency-Key generated once per logical operation and reused on every retry of
it; the server stores the key with the outcome and replays the stored response. Otherwise enforce a
natural uniqueness constraint and answer 409 Conflict, or move the
creation to PUT at a client-chosen URL.
Is it wrong to POST to a resource URL rather than a collection?
Not wrong — it means something else. POST to a specific resource asks that resource to
process the enclosed data, which is how actions outside create/read/update/delete are expressed, such as
POST /v1/orders/abc/refunds. What it does not mean is “create a resource at this URL”; that
instruction is PUT.