The actual difference
All three write. The distinction is not “create vs update” — that shorthand is wrong and causes most of the confusion. The three real questions are:
- Who chooses the URI? POST lets the server choose. PUT and PATCH target a URI the client already knows.
- Is the payload complete? PUT sends the whole resource. PATCH sends a diff.
- What happens on a retry? PUT and (well-designed) PATCH converge to the same state. POST creates a second thing.
Side-by-side comparison
| POST | PUT | PATCH | |
|---|---|---|---|
| Target | A collection | A specific resource | A specific resource |
| URI chosen by | Server | Client | Client (must exist) |
| Payload | New resource or action input | Complete representation | Partial diff |
| Idempotent | No | Yes | Only if absolute |
| Creates if absent | Yes (that is the point) | Yes (upsert) | No → 404 |
| Omitted fields | n/a | Cleared / reset to default | Left untouched |
| Typical success | 201 Created + Location | 200 or 204 | 200 with new state |
| Cacheable | No (practically) | No | No |
| Safe to blind-retry | No — needs an idempotency key | Yes | Yes if absolute |
When POST is right
- Creating into a collection where the server generates the id.
- Anything that is not a resource mutation: sending an email, running a job, cancelling an order.
- Operations that are inherently non-idempotent, like “append a comment”.
- Large or sensitive query payloads that must not sit in a URL or an access log.
POST /v1/articles
Content-Type: application/json
Idempotency-Key: 4b1e-90cc
{"title":"HTTP methods","body":"..."}
HTTP/1.1 201 Created
Location: /v1/articles/771When PUT is right
Use PUT when the client owns the full state and the identity. Configuration documents, user settings blobs, file uploads at a known path, and “sync this whole object from my local copy” flows are all natural PUTs.
PUT /v1/users/42/preferences
{"theme":"light","emailDigest":true,"timezone":"Asia/Kolkata"}
HTTP/1.1 200 OKIf-Match, or use PATCH.When PATCH is right
Use PATCH for field-level edits from a UI — toggling a flag, renaming something, changing a status. It is the smallest possible payload and it does not touch fields the client has never heard of, which makes it forward-compatible as the schema grows.
PATCH /v1/users/42
Content-Type: application/merge-patch+json
If-Match: "v19"
{"timezone":"Europe/London"}Two subtleties. PATCH on a non-existent resource should return 404, not create — creation via PATCH is a PUT with extra steps. And PATCH is idempotent only when the diff is absolute; a payload like {"op":"increment","field":"views"} breaks on every retry.
Merge Patch vs JSON Patch
| JSON Merge Patch (RFC 7396) | JSON Patch (RFC 6902) | |
|---|---|---|
| Content-Type | application/merge-patch+json | application/json-patch+json |
| Shape | Looks like the resource | Array of operations |
| Delete a field | Set it to null | { "op": "remove" } |
| Arrays | Replaced wholesale | Index-level add/remove/move |
| Set a field to null | Impossible — null means delete | Supported |
| Readability | High | Low |
| Best for | Most CRUD APIs | Documents, collaborative editing, precise array edits |
// Merge Patch
{ "name": "Ada", "nickname": null }
// JSON Patch
[
{ "op": "replace", "path": "/name", "value": "Ada" },
{ "op": "remove", "path": "/nickname" },
{ "op": "add", "path": "/tags/-", "value": "admin" }
]Pick Merge Patch unless you have a concrete need for array surgery or the ability to distinguish “set to null” from “remove”. JSON Patch is more powerful and materially harder to validate, authorise per field, and audit.
Concurrency: ETags and If-Match
Two users editing the same record is not an edge case, it is Tuesday. Optimistic concurrency with ETags turns a silent lost update into an explicit 412.
GET /v1/users/42 → 200, ETag: "v19" PATCH /v1/users/42 If-Match: "v19" → 200, ETag: "v20" PATCH /v1/users/42 If-Match: "v19" → 412 Precondition Failed
- The ETag can be a row version, an
updated_athash, or a content hash. - Use
If-None-Match: *on PUT to mean “create only if it does not exist”. - On 412 the client refetches and either retries or shows a conflict UI. Never auto-merge silently.
Retries and idempotency keys
A timeout is not a failure — it is an unknown. The request may have succeeded and only the response was lost. PUT and absolute PATCH are safe to resend because the end state is the same. POST is not, so you have to add the guarantee yourself.
// pseudo
const key = req.header("Idempotency-Key");
const seen = await store.get(key); // scoped per endpoint + user
if (seen) return respond(seen.status, seen.body);
const result = await createOrder(req.body);
await store.set(key, result, { ttl: "24h" }); // store BEFORE responding
return respond(201, result);- Store the key and the response inside the same transaction as the write, or you can still double-create.
- Reject a reused key with a different body — that is a client bug, return 422.
- Expire keys; 24 hours comfortably covers any sane retry policy.
Decision tree
Interview framing
“Creates are POST to the collection with an idempotency key, returning 201 and a Location header. Full-document writes — settings, uploads at a known path — are PUT, which is idempotent so our retry layer can resend them freely. UI field edits are PATCH with JSON Merge Patch and an If-Match ETag, so a stale editor gets a 412 instead of silently clobbering another user's change. We never PUT a partially-populated body, because omitted fields are semantically deletions.”