A URL identifies a target resource; the request method describes the operation the client asks the origin to apply. Together they form a contract read by the server, caches, proxies, crawlers, and retrying clients. A method is not a decoration on a path. It is a promise about the intended effect, and infrastructure can only make safe decisions when that promise is honest.
Safe and idempotent are different promises
Safe methods are defined as essentially read-only: GET and HEAD do not ask the origin to change state. A server may still record access logs or charge for a request; safety is about the requested target effect, not every incidental implementation action. Idempotency means that sending the same request more than once has the same intended server effect as sending it once. PUT and DELETE are idempotent by definition, but a response can still differ and a network failure can leave the client unsure whether the first request arrived.
POST usually asks the server to process a subordinate action or create a new resource. It is not idempotent merely because its handler happens to be written without a duplicate today. PATCH communicates partial modification but does not automatically promise idempotency. OPTIONS asks for communication options, and TRACE has a narrow diagnostic purpose. The origin still defines which methods are allowed on a particular target and should report that contract with Allow when returning 405.
Let the verb guide intermediaries
GET and HEAD can be prefetched or cached under the cache rules for the response. A crawler following a link must not trigger a destructive action simply because the action was hidden behind a GET URL. A retry library can consider an idempotent request for replay, but it still needs to account for authentication, deadlines, body replayability, and application preconditions. Idempotency is not a distributed transaction or a guarantee that a client will never see two responses.
PUT replaces the selected target representation, so repeating the same bytes converges on the same intended state. DELETE makes the target absent, although the first and later responses may be 204 and 404. If a service chooses an identifier and creates a new object, POST plus an idempotency key or a separate reservation record may be more accurate than pretending the operation is PUT. Write the method, response status, preconditions, and retry policy in the API contract rather than relying on folklore.
When it breaks
Using GET for a logout, purchase, or delete exposes that action to prefetchers, link scanners, browser history tools, and caches. Using POST for a full replacement hides the fact that a retry may create a second object. Treating every PUT as safe to retry without replaying its body can send an empty request or a different version. A handler that updates state for HEAD violates the method’s read-only intent even if its GET response looks correct.
Method checks also fail at boundaries. An endpoint may return 405 for a known but unsupported method, 501 for an unknown method, or 415 when the representation’s media type is unacceptable. A proxy may preserve the verb while dropping the body, or a redirect may change it; observe the actual wire request and the final response. Validate authorization and conditional headers separately. The method communicates intent, not permission, schema validity, or successful persistence.
Check it quickly
Save this as methods_probe.go and run it with Go 1.23 or newer. The loopback server stores one value, exposes GET/HEAD/PUT/DELETE/OPTIONS, rejects other methods, and enforces a small PUT body limit. It records the write count so repeated idempotent requests can be distinguished from safe reads. The state is in memory and the experiment does not claim a durable transaction.
package main
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
const allowed = "GET, HEAD, PUT, DELETE, OPTIONS"
var mu sync.Mutex
value, exists := "seed", true
var handled, writeCalls atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
defer r.Body.Close()
if r.URL.Path != "/item" {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet, http.MethodHead:
mu.Lock()
body, found := value, exists
mu.Unlock()
if !found {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
if r.Method == http.MethodGet {
fmt.Fprint(w, body)
}
case http.MethodPut:
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64))
if err != nil {
status := http.StatusBadRequest
var oversized *http.MaxBytesError
if errors.As(err, &oversized) {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, "body rejected", status)
return
}
mu.Lock()
created := !exists
value, exists = string(body), true
writeCalls.Add(1)
mu.Unlock()
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusNoContent)
}
case http.MethodDelete:
mu.Lock()
found := exists
value, exists = "", false
writeCalls.Add(1)
mu.Unlock()
if found {
w.WriteHeader(http.StatusNoContent)
} else {
http.NotFound(w, r)
}
case http.MethodOptions:
w.Header().Set("Allow", allowed)
w.WriteHeader(http.StatusNoContent)
case http.MethodPost, http.MethodPatch, http.MethodTrace, http.MethodConnect:
w.Header().Set("Allow", allowed)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
default:
http.Error(w, "unknown method", http.StatusNotImplemented)
}
}))
server.Config.ReadHeaderTimeout = time.Second
server.Config.ReadTimeout = 2 * time.Second
server.Start()
defer server.Close()
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
probe := func(method, payload string, status int, expected string) {
request, err := http.NewRequest(method, server.URL+"/item", strings.NewReader(payload))
must(err)
response, err := client.Do(request)
must(err)
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.Proto == "HTTP/1.1" && response.StatusCode == status, method+": wrong status/protocol")
check(string(body) == expected, method+": wrong response body")
if method == http.MethodHead {
check(response.ContentLength == 4 && len(body) == 0, "HEAD must report the GET length without its body")
}
if method == http.MethodOptions || status == 405 {
check(response.Header.Get("Allow") == allowed, "Allow must describe the target methods")
}
fmt.Printf("%s: status=%d body=%q\n", method, status, body)
}
probe("GET", "", 200, "seed")
probe("GET", "", 200, "seed")
probe("HEAD", "", 200, "")
check(writeCalls.Load() == 0, "safe requests must not call the write operation")
probe("PUT", "new", 204, "")
probe("PUT", "new", 204, "")
probe("GET", "", 200, "new")
check(writeCalls.Load() == 2, "both repeated PUT requests must reach the server")
probe("PUT", strings.Repeat("x", 65), 413, "body rejected\n")
probe("GET", "", 200, "new")
check(writeCalls.Load() == 2, "rejected PUT must leave state unchanged")
probe("DELETE", "", 204, "")
probe("DELETE", "", 404, "404 page not found\n")
probe("GET", "", 404, "404 page not found\n")
probe("OPTIONS", "", 204, "")
probe("POST", "", 405, "method not allowed\n")
probe("FROB", "", 501, "unknown method\n")
check(writeCalls.Load() == 4, "method discovery and rejection must not write")
probe("PUT", "ready", 201, "")
probe("GET", "", 200, "ready")
check(handled.Load() == 16 && writeCalls.Load() == 5, "wrong final request counts")
fmt.Println("sixteen requests; five PUT/DELETE handler calls; final state ready")
fmt.Println(runtime.Version())
}
The sixteen requests show two safe GETs and a bodyless HEAD before any write. Two identical PUTs both reach the origin but converge on the same new value; the oversized PUT is rejected with 413 and leaves that value intact. DELETE is idempotent in its state effect even though the first response is 204 and the second is 404. OPTIONS reports Allow, POST receives 405, and an unknown FROB method receives 501. A final PUT creates the missing target and GET confirms the recovery value. These results demonstrate method handling in this fixture; they do not prove cache freshness, authorization, network replay, or durable storage.
Further reading
Sources
- RFC 9110: HTTP Semantics, safe/idempotent methods, Allow, and status semantics, checked 2026-09-09.
- MDN: HTTP request methods, method behavior and practical usage, checked 2026-09-09.
- Go net/http, MaxBytesReader, request methods, and response handling, checked 2026-09-09.