net/http
The handler signature: ResponseWriter and *Request
Section titled “The handler signature: ResponseWriter and *Request”Every HTTP handler in Go has the same shape:
func(w http.ResponseWriter, r *http.Request)w comes first by convention, mirroring the rest of the standard library — fmt.Fprintln(w, ...), io.Copy(dst, src), json.NewEncoder(w) all put the thing being written to before the thing being read from or the data itself. There is no compiler rule enforcing the order; it is a readability convention net/http established and every framework built on top of it follows.
The two parameter types are different in a way that matters:
http.ResponseWriteris an interface (Header() Header,Write([]byte) (int, error),WriteHeader(int)). The concrete type behind it differs by context: a realnet.Conn-backed writer in production,*httptest.ResponseRecorderin a test. Some concrete types implement extra optional interfaces —http.Flusher,http.Hijacker— that a handler can type-assert for when it needs them (covered in Type Assertions).*Requestis a pointer to a concrete struct. ARequestcarries a body (io.ReadCloser), a context, and other fields a handler should never accidentally copy — copying aRequestby value would duplicate a struct that is meant to represent one single incoming request, while sharing (or worse, silently forking) the body reader underneath it. There is exactly one*Requestper call, and the pointer keeps it that way.
HandleFunc, NewServeMux, and the default mux
Section titled “HandleFunc, NewServeMux, and the default mux”http.HandleFunc(pattern, handler) is a package-level function. It registers the handler on http.DefaultServeMux, a single global *ServeMux variable that every call to http.HandleFunc anywhere in the program — your code, and any imported package’s init() — shares.
import _ "net/http/pprof" // registers /debug/pprof/* on DefaultServeMux as a side effect
func main() { http.HandleFunc("/hello", helloHandler) // now sharing a mux with pprof http.ListenAndServe(":8080", nil) // nil Handler means "use DefaultServeMux"}Importing net/http/pprof purely for its side effect (a blank _ import, the same pattern covered in Packages & Imports) registers profiling endpoints under /debug/pprof/ on that same global mux. If your production binary imports pprof anywhere in its dependency tree and also calls http.ListenAndServe(addr, nil), those profiling endpoints — which can dump goroutine stacks and heap contents — are reachable on the public port. This is a real, recurring incident category, not a hypothetical.
http.NewServeMux() creates a private mux instead, scoped to whatever you pass it as a Handler. Prefer it in any program with more than a handful of routes, and always in library code — a library that calls http.HandleFunc pollutes every program that imports it.
Routing since Go 1.22
Section titled “Routing since Go 1.22”As of Go 1.22, ServeMux patterns can include an HTTP method and wildcard path segments:
mux := http.NewServeMux()mux.HandleFunc("GET /items/{id}", itemHandler) // method + wildcard segmentmux.HandleFunc("POST /items", createHandler) // method, no wildcardmux.HandleFunc("GET /items/special", specialHandler) // more specific literalInside a matched handler, r.PathValue("id") returns the segment {id} captured from the URL. A trailing ... wildcard (/files/{path...}) captures the rest of the path instead of one segment.
Precedence: more specific wins. When two registered patterns could both match a request, ServeMux picks the more specific one — a literal segment beats a wildcard at the same position. Registering GET /items/{id} and GET /items/special and requesting each path in turn, verified locally:
/items/special -> 200 literal special/items/42 -> 200 wildcard id= 42/items/ -> 200 subtree catch-all, path= /items//items/a/b -> 200 subtree catch-all, path= /items/a/b/items/special matches the literal pattern, not the wildcard one, even though both could technically match; /items/ (registered as its own pattern with a trailing slash) acts as a subtree catch-all for anything under it that no more specific pattern claims.
Trailing slash redirects. If you register only GET /items/ (a subtree pattern) and a client requests /items (no trailing slash), ServeMux automatically replies with a redirect to add the slash — verified locally:
GET /items -> 307 /items/Ambiguous patterns are a registration-time panic, not a runtime routing decision. Two patterns that would match exactly the same set of requests — for example /items/{id} and /items/{name} — cannot both be registered:
panic: pattern "/items/{name}" (registered at ...) conflicts with pattern "/items/{id}" (registered at ...):/items/{name} matches the same requests as /items/{id}(file:line noise trimmed; the message itself is unedited from a local run). This fails immediately when the program starts, not on the first conflicting request — one more reason to prefer http.NewServeMux() plus a startup smoke test over the implicit DefaultServeMux.
http.Server and its timeouts
Section titled “http.Server and its timeouts”http.ListenAndServe(addr, handler) is a convenience wrapper around an http.Server with every field left at its zero value:
http.ListenAndServe(":8080", mux) // ReadTimeout, WriteTimeout, IdleTimeout: all zeroPer the http.Server documentation, a zero ReadTimeout, WriteTimeout, or IdleTimeout means no timeout at all — a slow or malicious client can hold a connection open indefinitely. ReadHeaderTimeout has the same zero-means-none rule but additionally falls back to ReadTimeout when it is zero and ReadTimeout is set. Production code should construct the Server explicitly:
srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, // caps time to read just the headers ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, // keep-alive connections}srv.ListenAndServe()ReadHeaderTimeout alone stops the most common slow-client attack (trickling headers one byte at a time) without limiting how long a legitimately slow request body upload is allowed to take — that is what ReadTimeout (whole request, including body) and per-handler deadlines via r.Context() are for.
Writing a response
Section titled “Writing a response”Three rules govern everything a handler writes to w:
- Set headers before calling
WriteHeaderorWrite.w.Header().Set(...)mutates a map that is only read once — the moment the status line goes out. Any header set after that point is silently ignored. WriteHeader(code)sends the status line and headers. Calling it more than once, or calling it afterWritehas already sent an implicit200 OK, does not change what the client sees — it only prints a warning to the server’s log (http: superfluous response.WriteHeader call from ...).http.Error(w, msg, code)is the standard way to fail a request. It setsContent-Type: text/plain; charset=utf-8, deletes any previously setContent-Length, writes the status code, and writesmsgfollowed by a newline — all three steps in the right order, so you rarely need to do this by hand.
if !ok { http.Error(w, "item not found", http.StatusNotFound) return}w.Header().Set("Content-Type", "application/json") // must come before Encodejson.NewEncoder(w).Encode(it) // Encode's first Write implies WriteHeader(200)json.NewEncoder(w).Encode(v) is the idiomatic JSON response pattern: it writes directly to w with no intermediate []byte, and its first byte written triggers the implicit 200 OK if you have not called WriteHeader yourself.
Reading a request body
Section titled “Reading a request body”json.NewDecoder(r.Body).Decode(&v) is the mirror image on the way in — it decodes straight from the stream instead of buffering the whole body into memory with io.ReadAll first. Two safety details matter for any body a client controls:
http.MaxBytesReader(w, r.Body, n)caps how many bytes a body may contain. Wrapr.Bodywith it before decoding; a body over the limit makesDecodereturn an error whose text is exactlyhttp: request body too large(verified locally) instead of letting an attacker stream gigabytes into your process.- You do not close
r.Bodyyourself. Per thenet/httpdocumentation: “For server requests, the Request Body is always non-nil… The Server will close the request body. The ServeHTTP Handler does not need to.” This is the one place in the whole package where the usual “you opened it, you close it” rule does not apply.
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies over 1 MiBvar in itemif err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return}The client side
Section titled “The client side”http.Get, http.Post, and friends at the package level use http.DefaultClient, whose zero value has Timeout: 0 — no timeout, per the http.Client documentation (“A Timeout of zero means no timeout”). A hung server or a network partition on the other end can block that call forever. Always construct a client with a timeout:
client := &http.Client{Timeout: 5 * time.Second}Three more mistakes are common enough that go vet and the standard library documentation both call them out directly:
-
defer resp.Body.Close()must come after the error check, never before.Client.Do’s documentation is explicit: “On error, any Response can be ignored” — meaningrespcan benilwhenerr != nil. Deferringresp.Body.Close()before checkingerrcalls.Body.Close()through a nil pointer.go vet’shttpresponseanalyzer catches this at build time:main.go:12:8: using resp before checking for errorsBypassing vet and running the same code anyway panics exactly as predicted:
panic: runtime error: invalid memory address or nil pointer dereferenceThe fix is always the same shape:
resp, err := client.Get(url)if err != nil {return err}defer resp.Body.Close() // resp is guaranteed non-nil here -
Dodoes not check the status code for you. Per the documentation: “A non-2xx status code doesn’t cause an error.” A404or500response is a perfectly successfulDocall witherr == nil— you must checkresp.StatusCodeyourself before trusting the body. -
io.ReadAll(resp.Body)has no size limit. A malicious or misbehaving server can stream an unbounded response and exhaust memory; wrap the body inio.LimitReader(resp.Body, n)first when the response size is not already trusted.
http.NewRequestWithContext(ctx, method, url, body) is the constructor to reach for whenever a request should be cancellable — it ties the request’s lifetime to a context.Context instead of only the client’s blanket Timeout, so a per-call deadline or a caller’s own cancellation propagates through.
Middleware
Section titled “Middleware”Middleware in net/http is nothing more than a function that takes a Handler and returns a new Handler wrapping it:
func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Printf("--> %s %s\n", r.Method, r.URL.Path) next.ServeHTTP(w, r) // call through, then (optionally) act again after })}Because the shape func(http.Handler) http.Handler always takes and returns the same interface, middleware composes by simple function application: loggingMiddleware(authMiddleware(mux)) runs logging first, then auth, then the mux — outermost wrapper runs first on the way in and last on the way out.
The playground below wires a handler, a mux with 1.22-style routes, a middleware chain, and a client together — server and client run in the same process via httptest.NewServer, so no external network is needed:
package main
import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "time")
type item struct { ID string `json:"id"` Name string `json:"name"`}
var items = map[string]item{"1": {ID: "1", Name: "widget"}}
// itemHandler reads the {id} path value that ServeMux extracted for us.func itemHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") it, ok := items[id] if !ok { http.Error(w, "item not found", http.StatusNotFound) // sets status + Content-Type + body return } w.Header().Set("Content-Type", "application/json") // header before WriteHeader json.NewEncoder(w).Encode(it) // Encode calls WriteHeader(200) implicitly}
// createHandler decodes a JSON body with a size cap.func createHandler(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies over 1 MiB var in item if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } items[in.ID] = in w.WriteHeader(http.StatusCreated) // must come before any Write json.NewEncoder(w).Encode(in)}
// loggingMiddleware wraps a Handler and returns a Handler -- the chaining shape.func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Printf("--> %s %s\n", r.Method, r.URL.Path) next.ServeHTTP(w, r) })}
func main() { mux := http.NewServeMux() mux.HandleFunc("GET /items/{id}", itemHandler) mux.HandleFunc("POST /items", createHandler)
// httptest.NewServer starts a real listener on localhost so the client // below talks real HTTP, but everything stays inside this one process. ts := httptest.NewServer(loggingMiddleware(mux)) defer ts.Close()
client := &http.Client{Timeout: 5 * time.Second} // never use the default client without one
resp, err := client.Get(ts.URL + "/items/1") if err != nil { fmt.Println("GET error:", err) return } defer resp.Body.Close() // deferred AFTER the error check, not before body, _ := io.ReadAll(resp.Body) fmt.Println("GET /items/1:", resp.StatusCode, string(body))
resp2, err := client.Get(ts.URL + "/items/999") if err != nil { fmt.Println("GET error:", err) return } defer resp2.Body.Close() // Do never treats a 4xx/5xx as an error -- you must check StatusCode yourself. if resp2.StatusCode != http.StatusOK { body2, _ := io.ReadAll(resp2.Body) fmt.Println("GET /items/999:", resp2.StatusCode, string(body2)) }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() payload, _ := json.Marshal(item{ID: "2", Name: "gadget"}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/items", bytes.NewReader(payload)) if err != nil { fmt.Println("build request:", err) return } resp3, err := client.Do(req) if err != nil { fmt.Println("POST error:", err) return } defer resp3.Body.Close() body3, _ := io.ReadAll(resp3.Body) fmt.Println("POST /items:", resp3.StatusCode, string(body3))} Real output from this program on Go 1.27.0:
sequenceDiagram
participant Client
participant Server
Client->>Server: GET /items/1
Server-->>Client: 200 {"id":"1","name":"widget"}
Client->>Server: GET /items/999
Server-->>Client: 404 item not found
Client->>Server: POST /items
Server-->>Client: 201 {"id":"2","name":"gadget"} Context cancellation and graceful shutdown
Section titled “Context cancellation and graceful shutdown”Every incoming *Request carries a context.Context reachable through r.Context(). Per the documentation, that context “is canceled when the client’s connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP method returns.” A handler that starts slow work should pass r.Context() down and check ctx.Err() (or select on ctx.Done()) periodically — otherwise the handler keeps running and consuming resources long after the client has given up and gone away.
Shutting the whole server down cleanly needs the same idea at the process level: stop accepting new connections, but let in-flight handlers finish. signal.NotifyContext turns an OS signal into a context.Context, and srv.Shutdown(ctx) does the actual graceful stop:
srv := &http.Server{Handler: mux}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)defer stop()
go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) }}()
<-ctx.Done() // blocks until SIGINT/SIGTERMstop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)defer cancel()if err := srv.Shutdown(shutdownCtx); err != nil { log.Println("forced shutdown:", err)}srv.Shutdown closes the listener immediately, then waits (up to the deadline) for every in-flight handler to return on its own. ListenAndServe unblocks with exactly http.ErrServerClosed — the expected, non-error outcome of a deliberate shutdown, not a failure to log. A deterministic twin of this — an OS-assigned port and a timer instead of a real OS signal — verified locally:
server shut down cleanlyServe returned ErrServerClosed: trueThis snippet needs a real TCP listener and OS signals, so — like the concurrency module’s graceful shutdown example — it does not run in this page’s browser-based Playground.
Testing with httptest
Section titled “Testing with httptest”httptest.NewRecorder() implements http.ResponseWriter entirely in memory, so a handler can be exercised directly with no listener, no goroutine, and no network at all:
package main
import ( "encoding/json" "fmt" "net/http" "net/http/httptest")
type item struct { ID string `json:"id"` Name string `json:"name"`}
var items = map[string]item{"1": {ID: "1", Name: "widget"}}
func itemHandler(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") it, ok := items[id] if !ok { http.Error(w, "item not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(it)}
func main() { mux := http.NewServeMux() mux.HandleFunc("GET /items/{id}", itemHandler)
// httptest.NewRequest + httptest.NewRecorder test the handler directly -- // no listener, no goroutine, no network at all. req := httptest.NewRequest(http.MethodGet, "/items/1", nil) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req)
fmt.Println("status:", rec.Code) fmt.Println("content-type:", rec.Header().Get("Content-Type")) fmt.Println("body:", rec.Body.String())
// A request for a missing id exercises the error path the same way. req2 := httptest.NewRequest(http.MethodGet, "/items/999", nil) rec2 := httptest.NewRecorder() mux.ServeHTTP(rec2, req2) fmt.Println("missing status:", rec2.Code, "body:", rec2.Body.String())} Real output from this program on Go 1.27.0:
status: 200content-type: application/jsonbody: {"id":"1","name":"widget"}
missing status: 404 body: item not foundhttptest.NewServer is the other half of the toolkit — it starts a real listener (used by the main playground above) for tests that need actual round-trip HTTP, such as exercising a client library or checking that headers survive a real socket. Reach for NewRecorder for a single handler in isolation, and NewServer when the client under test cannot be pointed at anything other than a real URL.
Request lifecycle
Section titled “Request lifecycle”flowchart TD C["Client"] -- "TCP connect + HTTP request" --> S["http.Server (ReadHeaderTimeout, ReadTimeout)"] S -- "ServeHTTP(w, r)" --> MW["Middleware chain"] MW -- "next.ServeHTTP(w, r)" --> H["Handler: r.PathValue, json.NewDecoder(r.Body)"] H -- "Header().Set, then WriteHeader, then Write" --> R["status line + headers + body"] R --> C Ctx["r.Context()"] -. "cancelled if the client disconnects" .-> H
Mental Model
Section titled “Mental Model”flowchart TD
Srv["http.Server{Addr, Handler, ReadTimeout, WriteTimeout, ...}"] --> DSM["DefaultServeMux (global, shared by every http.HandleFunc call)"]
Srv --> PSM["*ServeMux (private, created with http.NewServeMux())"]
DSM --> MW["middleware(middleware(handler)) -- outermost runs first in, last out"]
PSM --> MW
MW --> H["func(w http.ResponseWriter, r *http.Request)"]
H --> PV["r.PathValue('id') -- from the matched route pattern"]
H --> Ctx["r.Context() -- cancelled if the client disconnects"]
H --> Dec["json.NewDecoder(r.Body) -- server closes r.Body for you"]
H --> Resp["w.Header().Set(...) then w.WriteHeader(code) then w.Write(body)"]
Resp --> Note["must happen in this order -- headers are frozen at WriteHeader"] Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
http.DefaultServeMux via http.HandleFunc | Zero setup, one line per route | Global mutable state shared with every imported package |
http.NewServeMux() | Isolated routing, safe in library code | One more variable to thread through your program |
| 1.22 method + wildcard routing | No third-party router needed for most APIs | Ambiguous patterns panic at startup; no regex constraints on segments |
http.Server with explicit timeouts | Protects against slow-client and resource-exhaustion attacks | More fields to reason about than ListenAndServe(addr, nil) |
httptest.NewRecorder | Fast, in-memory handler tests | Does not exercise real TCP behavior (chunking, connection reuse) |
Common Misconceptions
Section titled “Common Misconceptions”ListenAndServe(addr, nil)is a safe default — every timeout field on the underlyinghttp.Serveris zero, meaning none of them are enforced; a slow or hostile client can hold a connection open forever.resp.Body.Close()is safe to defer immediately afterDo/Get—respcan benilwhenerr != nil; deferring the close before checkingerrpanics with a nil pointer dereference, andgo vet’shttpresponseanalyzer flags exactly this pattern.- A successful
Docall means a successful request —Doonly returns an error for network/policy failures; a404or500iserr == nilwith a status code you must check yourself. - Calling
WriteHeadertwice, or afterWrite, changes what the client sees — it does not. The first byte written (or the firstWriteHeadercall) freezes the status and headers; anything after that only produces asuperfluous response.WriteHeader calllog line. - You must close
r.Bodyin a handler, symmetrically with closingresp.Bodyon the client — the server does this for you; only the client side needs an explicitClose.
💡 Real-world example
net/http/pprof: importing it for its side effect registers/debug/pprof/*onhttp.DefaultServeMux— the exact mechanism described above for why the global mux is dangerous in a program that also callshttp.ListenAndServe(addr, nil).
http.ErrServerClosed: returned byServe/ListenAndServeafter a call toServer.Shutdown, and treated as the non-error, expected outcome of a graceful shutdown rather than logged as a failure.
Reading AI-generated code
Section titled “Reading AI-generated code”net/http code is some of the most common Go an AI assistant writes, and also some of the most reliably wrong on exactly the points above. Check for these before trusting a review:
- Does every
http.Clientused for a real network call have aTimeoutset, or is it the zero-value default client? - Is
defer resp.Body.Close()placed after theif err != nilcheck, never before it? - Is
resp.StatusCodechecked afterDo/Get, or does the code assume a nil error means success? - Does a handler call
w.Header().Set(...)before the firstWriteorWriteHeader, never after? - Does the program call
http.HandleFunc/http.ListenAndServe(addr, nil)while also importing a package (likenet/http/pprof) that registers routes on the same global mux? - Does a goroutine spawned inside a handler carry
r.Context()forward, or does it keep running (and consuming resources) after the client disconnects?
🐛 Spot the bug
func createHandler(w http.ResponseWriter, r *http.Request) { var in Item if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } body, _ := json.Marshal(in) w.Write(body) w.WriteHeader(http.StatusCreated)}Show the answer
w.Write(body) sends the response body immediately, and since no status was set yet, Go implicitly sends 200 OK as part of that first write. The w.WriteHeader(http.StatusCreated) on the next line is too late — the status line already went out. Verified locally: the client sees status 200, never 201, and the server logs
http: superfluous response.WriteHeader call from main.createHandler (main.go:23)Fix it by calling WriteHeader before the first Write (or before the encoder’s first internal write):
w.WriteHeader(http.StatusCreated)w.Write(body)or, using the encoder directly, w.WriteHeader(http.StatusCreated) followed by json.NewEncoder(w).Encode(in).