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