Backend Systems · API Styles
Safety and Idempotence in HTTP Method Semantics
What each method promises about side effects, how caches and retrying intermediaries use those promises, and where real endpoints quietly break them.
HTTP methods are not labels chosen for readability. Each carries a small set of promises about what a request may do to server state, and those promises are used by software the origin server never sees: browser caches, content delivery networks, reverse proxies, load balancers, and client retry libraries. A method used contrary to its promise does not merely look wrong in a design review; it makes intermediaries behave incorrectly in ways that are difficult to reproduce, because the incorrect behaviour happens outside the server’s logs.
Two properties carry most of the weight. A method is safe when the client did not request a state change, and idempotent when the intended effect on the server is the same whether the request is applied once or several times. RFC 9110 defines both, along with the smaller property of cacheability, and it lists nine methods: GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, and TRACE in the core specification, with PATCH defined separately in RFC 5789.
The three properties, stated precisely
Safety is about the request, not the response. GET, HEAD, OPTIONS, and TRACE are safe. A safe request may still change server state in incidental ways: logging, rate-limit counters, and cache population all happen. What safety forbids is a change the client asked for, and it obliges the server not to charge for the request or hold the client responsible for effects, which is why prefetching and crawling are permitted.
Idempotence is about the effect of repetition. GET, HEAD, OPTIONS, TRACE, PUT, and DELETE are idempotent; POST and PATCH are not, and CONNECT sits outside the question. The qualifier “intended effect” matters: a DELETE that returns 404 on the second call has still had the intended effect, so a client or proxy may repeat it after a timeout. A PUT that appends to a log is not idempotent in practice even if it uses the PUT method, and repeating it duplicates entries.
Cacheability is a property of the response as much as the method. GET and HEAD responses are cacheable by default when they carry the metadata to support it. POST responses are cacheable only when the response explicitly includes freshness information, and most caches do not store them. Responses to unsafe methods invalidate cached representations of the target URI, and of the URIs named in the response’s Location and Content-Location headers, which is how a PUT or DELETE keeps a cache from serving stale content.
| Method | Safe | Idempotent | Cacheable by default |
|---|---|---|---|
| GET | yes | yes | yes |
| HEAD | yes | yes | yes |
| POST | no | no | no |
| PUT | no | yes | no |
| DELETE | no | yes | no |
| PATCH | no | no | no |
Why PUT replaces and PATCH edits
PUT sends a complete representation that should replace whatever is stored at the target URI. The server’s job is to make the resource’s state match the enclosed representation, not to merge it. That replacement semantics is what makes PUT idempotent: applying the same complete representation twice leaves the same state. It is also why a PUT that omits a field should clear that field rather than preserve it, a behaviour that surprises clients which treat PUT as an upsert of supplied columns.
PATCH, defined in RFC 5789, exists for partial modification and is explicitly not idempotent in general. Its body is a patch document interpreted according to the request media type, and the server must apply it atomically: if any part fails, none of it takes effect. RFC 6902 defines JSON Patch as an ordered list of operations with add, remove, replace, move, copy, and test, and its test operation gives a limited form of precondition checking. RFC 7396 defines JSON Merge Patch, a much simpler format in which the body is a partial object, a member set to null deletes the corresponding field, and nested objects merge recursively. Merge Patch cannot express “set this field to null” without ambiguity, and it cannot append to an array; it replaces arrays wholesale.
The choice between them is a choice about concurrency and expressiveness. Merge Patch is easy to produce by hand and easy to reason about for flat objects. JSON Patch can express array edits and conditional assertions but requires the client to know the current document structure, including array indices, which shift under concurrent modification. Neither is idempotent in the general case, so a retried PATCH needs a precondition or an explicit request identifier, the subject of idempotency keys for mutation retries.
Preconditions turn lost updates into errors
The most common concurrency bug in an HTTP API is the lost update: two clients read the same representation, both compute a new state, and the second write silently discards the first. Method semantics alone do not prevent this. Conditional requests do.
An ETag is an opaque validator for a specific representation. A client that has read a resource sends If-Match: "7f3a" with its write; the server compares the validator with the current one and, if they differ, returns 412 Precondition Failed without applying the change. If-None-Match does the inverse and is how a conditional GET is made to return 304 Not Modified, and how a PUT with If-None-Match: * is made to fail if the resource already exists, which gives create-only semantics. The asterisk form is a useful detail: If-Match: * means “only if a current representation exists”, while If-None-Match: * means “only if none exists”.
Strong and weak validators differ in what they promise. A strong ETag changes whenever the representation bytes change; a weak ETag, written with a W/ prefix, may stay the same across byte changes that are not semantically meaningful. If-Match requires a strong comparison, so a weak validator cannot be used for it, while If-None-Match permits weak comparison. Servers that generate ETags from a hash of the response body get strong validators for free; servers that use a row version get strong validators only if the representation is a pure function of that version.
Status codes that follow from the contract
The response codes are part of the method contract, and using them consistently is what lets client libraries make correct decisions. A successful PUT or DELETE that produces no body returns 204 No Content. A PUT that creates a resource returns 201 Created with a Location header naming the new resource. A POST that creates a subordinate resource does the same; a POST that merely processes data returns 200 or 204.
A method the resource does not support returns 405 Method Not Allowed with an Allow header listing what it does support. The header is required, and a 405 without it is a specification violation that leaves clients guessing. A request that would violate a resource’s invariants returns 409 Conflict, and a request that could be satisfied but fails validation returns 422 Unprocessable Content. A missing precondition on an unsafe request that the server requires to be conditional returns 428 Precondition Required, which turns an optional concurrency guard into a mandatory one.
There is a subtlety with 404 and authorization. Returning 404 instead of 403 for a resource the caller may not see avoids disclosing existence, but it also makes the response indistinguishable from a genuinely absent resource, which is a decision about information leakage rather than method semantics.
Where implementations break the promises
Three patterns account for most violations. The first is GET with side effects, typically a link that marks a message read or a GET /logout. Any cache or prefetcher may issue it, and browsers do; the effect is not reproducible because it depends on client behaviour. The second is a non-idempotent PUT, usually an append or an increment, which breaks automatic retry in proxies and client libraries. The third is a DELETE that returns 500 on the second call because the handler assumes the row exists, which is arguably correct as a database error but wrong as an HTTP response: deleting an already-absent resource should succeed idempotently, and 404 or 204 are both defensible, but 500 is not.
PATCH has a fourth failure mode: concurrent patch application. Two merge patches that both read and rewrite the same nested object can interleave, and the result depends on arrival order. Atomic application at the storage layer makes each patch all-or-nothing but does not serialise them. A version check or a database transaction with an appropriate isolation level does. The HTTP-level remedy, an If-Match precondition, works only when the server exposes a validator that changes on every relevant write.
HEAD is often implemented by running the GET handler and discarding the body, which is correct for metadata but wasteful for expensive representations and can trigger side effects in handlers that were never meant to run for a metadata probe. A dedicated handler that computes Content-Length and validators without materialising the body is both faster and safer. OPTIONS is rarely useful across origins in the presence of CORS preflight, but it remains the standard way to discover the Allow set for a resource.
None of this makes a design correct on its own. It makes the exchange predictable to the layer above it, which is the entire value of using HTTP rather than a bespoke protocol. Interfaces that pick methods by habit forfeit that value while still paying the cost of the envelope, and the loss surfaces most sharply when errors have to be classified as retryable or not.
References
- RFC 9110: HTTP Semantics — IETF
- RFC 5789: PATCH Method for HTTP — IETF
- RFC 7396: JSON Merge Patch — IETF