net/http Clients and Servers
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27.
The standard library alone is enough to write a production HTTP client and a production HTTP server in Go: net/http
has a connection-pooling client, an HTTP/1.1 and HTTP/2 server, a router with method and wildcard patterns since
Go 1.22, protection against cross-site request forgery since Go 1.25, a reverse proxy, static file serving and a
complete test toolkit. This page covers all of it, with the settings that the defaults leave dangerous (no timeouts, no
body limits) set deliberately, and ends with a short tour of the layer below: raw TCP with net, and the net/netip
and net/url helper packages.
It builds on Context (every request carries one and every client call
accepts one), Interfaces (http.Handler is the interface that Go web
code is built around), Functions, Closures and Defer
(middleware is closures returning closures) and Error Handling
(errors.As on *url.Error and *http.MaxBytesError). The neighbours are
encoding/json (the wire format of the shelf API),
Structured Logging with slog (request logging),
Time and Timers,
database/sql (the layer behind the handlers),
I/O, Files and the Filesystem (io.Reader, fs.FS),
Testing and
Benchmarks, Fuzzing and synctest, and
Crypto and Security (TLS configuration, html/template and
other hardening). On this site, the same problems in other stacks are covered by
Axum (see its graceful shutdown and
request limits and timeouts pages for the Rust equivalents),
NestJS security headers, CORS and CSRF and the
Rust async/await and futures page.
Every example was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output. Servers
in the examples listen on 127.0.0.1 with a system-chosen port (httptest.NewServer, or net.Listen("tcp",
"127.0.0.1:0")) and are short-lived, and the examples that depend on timing use large safety margins and print only
facts that do not vary between runs. The go.mod of the example module is:
module example.com/shelf
go 1.27
The running scenario is shelf, the library catalogue shared by the whole Go Reference: the catalog package has
Book, Author, ISBN (a validated defined type), a Store interface and the mutex-guarded MemStore, and
cmd/shelfd exposes it as a JSON API. The catalog package is not repeated here; the code below uses it exactly as
described on the Interfaces page.
The package at a glance
net/http has two halves that share the Request, Response and Header types. The client half sends requests; the
server half receives them and calls your code. Almost everything is an interface or a function type, so almost
everything can be wrapped, faked and tested without a network.
| Name | Role |
|---|---|
|
Sends requests with policy: timeout, redirects, cookies. Safe for concurrent use; create one and reuse it. |
|
The |
|
An outgoing request (client) or an incoming one (server). Carries a |
|
What the client gets back. |
|
|
|
The built-in router (patterns with methods and wildcards since Go 1.22). It is itself an |
|
Listens, accepts connections, enforces timeouts and calls the handler once per request, each in its own goroutine. |
|
Per-response control from inside a handler: flush, deadlines, hijack, full duplex (Go 1.20). |
|
CSRF defence based on browser fetch metadata (Go 1.25). |
|
|
|
|
|
Hooks into the client’s connection life cycle (DNS, connect, TLS, first byte). |
|
An in-memory cookie jar for |
A request on the wire
HTTP/1.1 is a text protocol: a request line, headers, an empty line and an optional body, answered by a status line,
headers, an empty line and an optional body (RFC 9110 defines the semantics,
RFC 9112 the HTTP/1.1 syntax). HTTP/2 carries the same semantics in binary
frames, and net/http hides the difference: the same Handler serves both. This is the exchange of a real curl -i
against the shelfd program that is built later on this page (the Date header is omitted):
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 70
[{"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"]}]
Four details matter for the rest of the page. The status code belongs to the response, and an HTTP error status such
as 404 is a successful exchange from the client library’s point of view (a transport failure is an error; a 404 is
not). Content-Length and Content-Type tell the peer how to read the body. X-Content-Type-Options: nosniff is added
by http.Error so that a browser does not guess a different type for an error body. And the method has a meaning
that routers and security code rely on: GET, HEAD and OPTIONS are safe (they must not change state), PUT and
DELETE are idempotent (repeating them has the same effect), and POST is neither. Retry logic and CSRF protection
both depend on that classification.
The client
Never use the default client in production
http.Get, http.Post and http.DefaultClient are conveniences for scripts and tests. http.DefaultClient is the
zero http.Client: it has no timeout. A server that accepts the connection and then never answers keeps the
calling goroutine, its connection and its memory alive forever, and in a service that multiplies by the request rate.
Every production client sets a timeout. Client.Timeout bounds the whole exchange: dialling, TLS, sending the request,
following redirects and reading the response body (the timer keeps running after Do returns and interrupts reads of
Response.Body).
package main
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"time"
)
func main() {
// A server that never answers within the test.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(5 * time.Second):
}
}))
defer srv.Close()
client := &http.Client{Timeout: 150 * time.Millisecond}
start := time.Now()
_, err := client.Get(srv.URL + "/slow")
var uerr *url.Error
if errors.As(err, &uerr) {
fmt.Println("op:", uerr.Op)
fmt.Println("err:", uerr.Err)
fmt.Println("timeout:", uerr.Timeout())
}
var nerr net.Error
fmt.Println("net.Error:", errors.As(err, &nerr) && nerr.Timeout())
fmt.Println("deadline exceeded:", errors.Is(err, context.DeadlineExceeded))
fmt.Println("returned quickly:", time.Since(start) < 2*time.Second)
}
op: Get
err: context deadline exceeded (Client.Timeout exceeded while awaiting headers)
timeout: true
net.Error: true
deadline exceeded: true
returned quickly: true
Source: net/http: Client
Every error returned by Client.Do, Get, Post and friends has the dynamic type *url.Error; Op is the method
name, URL the request URL, Err the cause, and Timeout() reports whether it was a timeout. A client timeout
satisfies errors.Is(err, context.DeadlineExceeded) and net.Error.Timeout(), so the error handling pattern from
Error Handling works unchanged. (The error message also contains the
full URL, which is why this example prints the parts instead of the whole message: the port differs on every run, and in
real programs a URL with credentials in the query string would end up in your logs.)
There are several independent time limits, and they answer different questions:
| Limit | Covers | Where it is set |
|---|---|---|
|
The whole call including redirects and reading the body. |
|
The request context |
The same, but per request and cancellable by the caller (deadline, cancel, parent cancellation). |
|
Dial timeout |
Establishing the TCP connection. |
|
|
The TLS handshake. |
|
|
From the end of the request to the response headers (not the body). |
|
|
Waiting for |
|
|
How long an unused pooled connection is kept. |
|
A request context and Client.Timeout combine: whichever expires first ends the call. Prefer a context per call (the
caller knows the budget) plus a conservative Client.Timeout as the safety net. The context rules, including why a
context is the first parameter of the function that does the call and never a struct field, are on the
Context page.
A request with a context, and what to do with the response
The pattern for every call is the same: build the request with a context, set headers, Do, check the error, close
the body, check the status code, then read a bounded amount of the body.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
"example.com/shelf/catalog"
)
// StatusError reports an unexpected HTTP status.
type StatusError struct {
Code int
Body string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("unexpected status %d: %s", e.Code, e.Body)
}
// getBook fetches one book from the shelf API.
func getBook(ctx context.Context, c *http.Client, base string, isbn catalog.ISBN) (catalog.Book, error) {
var b catalog.Book
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/books/"+string(isbn), nil)
if err != nil {
return b, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.Do(req)
if err != nil {
return b, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return b, &StatusError{Code: resp.StatusCode, Body: string(msg)}
}
// Never trust the size of a remote body.
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&b)
return b, err
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /books/{isbn}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("isbn") != "9780441172719" {
http.Error(w, "no such book", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(catalog.Book{
ISBN: "9780441172719",
Title: "Dune",
Authors: []catalog.Author{{Name: "Frank Herbert"}},
})
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
b, err := getBook(ctx, client, srv.URL, "9780441172719")
fmt.Printf("%+v %v\n", b, err)
_, err = getBook(ctx, client, srv.URL, "9780000000002")
var se *StatusError
fmt.Println(errors.As(err, &se), err)
}
{ISBN:9780441172719 Title:Dune Authors:[{Name:Frank Herbert}]} <nil>
true unexpected status 404: no such book
Source: net/http: NewRequestWithContext
-
http.NewRequestWithContext(ctx, method, url, body)is the constructor to use.http.NewRequestis the same withcontext.Background(); there is no reason to prefer it in code that has a context. Method names are the constantshttp.MethodGet,http.MethodPostand so on. -
A status code is not an error.
Doreturns a nil error for 404 and 500 responses. The helper above turns an unexpected status into a*StatusErrorthat callers can match witherrors.As, and reads at most 512 bytes of the body for the message. -
defer resp.Body.Close()goes after the error check. IfDoreturned an error,respisniland the deferred call would panic with a nil pointer dereference.go vethas an analyzer,httpresponse, for exactly this mistake:package main import ( "fmt" "net/http" ) func main() { resp, err := http.Get("http://127.0.0.1:1/") defer resp.Body.Close() if err != nil { fmt.Println(err) return } fmt.Println(resp.Status) }main.go:10:8: using resp before checking for errorsSource: net/http: Get and httpresponse analyzer
-
Closing the body is not optional. It releases the connection back to the pool. An HTTP/1.1 connection can be reused only after the response body has been read to the end (or closed): the documentation warns that a body not read to EOF and closed may keep the transport from reusing the connection, which defeats pooling. Go 1.27 changed that: closing an HTTP/1
Response.Bodynow drains unread content up to a conservative limit so the connection can be reused. A body larger than the limit still costs a connection, so reading the part you need and closing is correct, andio.Copy(io.Discard, io.LimitReader(resp.Body, n))beforeCloseremains a valid way to make the intent explicit. -
Bound what you read. A remote server controls the size of its response.
io.LimitReader(orhttp.MaxBytesReaderon the server side) keeps a hostile or buggy peer from exhausting memory. The decoder in the example reads at most one mebibyte. -
AcceptandContent-Typeare the caller’s job. The client sets only a few headers itself (User-Agent,Accept-Encoding: gzipwith transparent decompression,Content-Length);Header.Setcanonicalises the key (content-typebecomesContent-Type). -
Sending a body.
http.NewRequestWithContextrecognises*bytes.Reader,*bytes.Bufferand*strings.Reader, setsContentLengthand fillsRequest.GetBody, which lets the client replay the body on a redirect or after a connection failure. With any other reader (a pipe, an*os.Filewrapped in another reader) the length is unknown and the body is sent chunked and cannot be replayed.http.NoBodymarks an explicitly empty body,http.PostFormsends aurl.Valuesasapplication/x-www-form-urlencoded, and for JSON you marshal into a buffer and setContent-Typeyourself (see encoding/json).
Transport, connection pooling and DialContext
http.Client is policy; http.Transport is machinery and holds the connection pool. Two consequences follow. First,
reuse one Client (and its Transport) for all calls to the same service: a new client per request defeats
connection reuse, leaks idle connections and repeats TLS handshakes. Second, the settings that matter most for a service
that calls other services are on the transport. Start from a clone of http.DefaultTransport (a fresh &http.Transport{}
loses its proxy-from-environment setting, its dialer and its HTTP/2 enablement) and change what you need:
package main
import (
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"time"
)
// newClient returns a client for a service we call often.
func newClient() *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = (&net.Dialer{
Timeout: 3 * time.Second, // TCP connect
KeepAlive: 30 * time.Second,
}).DialContext
tr.TLSHandshakeTimeout = 3 * time.Second
tr.ResponseHeaderTimeout = 5 * time.Second
tr.IdleConnTimeout = 90 * time.Second
tr.MaxIdleConns = 100
tr.MaxIdleConnsPerHost = 10 // the default of 2 is too low for a busy client
return &http.Client{Transport: tr, Timeout: 10 * time.Second}
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}))
defer srv.Close()
c := newClient()
for i := 1; i <= 2; i++ {
var reused bool
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused },
}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
resp, err := c.Do(req)
if err != nil {
fmt.Println(err)
return
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
fmt.Printf("request %d: connection reused: %t\n", i, reused)
}
tr := c.Transport.(*http.Transport)
fmt.Println("MaxIdleConnsPerHost:", tr.MaxIdleConnsPerHost)
fmt.Println("default MaxIdleConnsPerHost:", http.DefaultMaxIdleConnsPerHost)
}
request 1: connection reused: false
request 2: connection reused: true
MaxIdleConnsPerHost: 10
default MaxIdleConnsPerHost: 2
Source: net/http: Transport
-
DialContextis the hook for everything below HTTP: timeouts, keep-alive probes, a custom resolver (net.Dialer.Resolver), binding to a local address, dialling a Unix socket. The oldTransport.Dialfield (the one used in older tutorials) has no context and is deprecated. -
MaxIdleConnsPerHostdefaults tohttp.DefaultMaxIdleConnsPerHost, which is 2. A service that makes many concurrent calls to one backend keeps opening and closing connections beyond the second one; raise it to the concurrency you need.MaxConnsPerHost(default unlimited) caps the total, idle plus in use, and makes callers wait. -
ForceAttemptHTTP2is true inDefaultTransport. A customTransportthat setsDialContextorTLSClientConfigdisables automatic HTTP/2 unless it sets this field again; cloning the default keeps it. -
Proxy: http.ProxyFromEnvironmenthonoursHTTP_PROXY,HTTPS_PROXYandNO_PROXY; setProxytonilfor none or to your own function for per-URL decisions. -
Client.CloseIdleConnections()(andTransport.CloseIdleConnections()) drop pooled connections, which matters in tests and at shutdown. -
net/http/httptraceattaches aClientTraceto a request context. The example usesGotConnto show that the second request reused the first connection;DNSStart,ConnectDone,TLSHandshakeDoneandGotFirstResponseBytegive a latency breakdown when you are debugging a slow call. -
Transport.NewClientConn(Go 1.26) returns one client connection for callers that must manage connections themselves. Most programs should keep usingRoundTripthrough aClient, which manages the pool.
Redirects and sensitive headers
By default the client follows up to 10 redirects and then fails with stopped after 10 redirects. 301, 302 and 303
turn a POST into a GET without a body; 307 and 308 keep the method and replay the body through GetBody. When a
redirect leads to a different domain, the client drops Authorization, WWW-Authenticate and Cookie headers, so a
token is not forwarded to a third party. CheckRedirect replaces the policy; returning http.ErrUseLastResponse
hands back the redirect response itself:
package main
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /old", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/new", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /new", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "you are here")
})
mux.HandleFunc("GET /loop", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/loop", http.StatusFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
// 1. The default client follows up to 10 redirects.
resp, err := http.Get(srv.URL + "/old")
if err != nil {
fmt.Println(err)
return
}
fmt.Println("followed:", resp.StatusCode, resp.Request.URL.Path)
resp.Body.Close()
// 2. Do not follow: inspect the redirect itself.
noFollow := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err = noFollow.Get(srv.URL + "/old")
if err != nil {
fmt.Println(err)
return
}
fmt.Println("not followed:", resp.StatusCode, resp.Header.Get("Location"))
resp.Body.Close()
// 3. A loop stops after 10 redirects.
_, err = http.Get(srv.URL + "/loop")
var uerr *url.Error
fmt.Println("loop:", errors.As(err, &uerr), uerr.Err)
}
followed: 200 /new
not followed: 301 /new
loop: true stopped after 10 redirects
Source: net/http: Client
Cookies
A nil Client.Jar means cookies are neither stored nor sent. net/http/cookiejar provides an in-memory jar that
applies the usual domain, path, Secure and expiry rules and is consulted for every redirect hop; the cookie example
in Forms and cookies below shows a
client with a jar logging in and being recognised on the next request. Cookie.Quoted and Request.CookiesNamed
(Go 1.23) and ParseCookie and ParseSetCookie (Go 1.23) cover the less common cases. Since Go 1.26 a client with a
jar also uses and stores cookies against the host from Request.Host when it is set, not only the connection address.
Retrying
The standard library does not retry. Retrying is a policy decision: only idempotent requests are safe to repeat blindly
(GET, HEAD, PUT, DELETE; a POST needs an idempotency key), only transient statuses are worth repeating
(429, 502, 503, 504) and the wait must grow and be bounded by the caller’s context. The helper below does the minimum
correctly, including draining and closing each failed response and honouring Retry-After; for the same ideas as
configuration in other stacks see Resilience4j retry and
time limiter.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"time"
)
// getWithRetry retries an idempotent GET on 429, 502, 503 and 504.
// It honours Retry-After (seconds) and stops as soon as ctx is done.
func getWithRetry(ctx context.Context, c *http.Client, url string, attempts int) (*http.Response, error) {
backoff := 10 * time.Millisecond
for attempt := 1; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.Do(req)
if err == nil && !retryable(resp.StatusCode) {
return resp, nil // the caller closes the body
}
wait := backoff
if err == nil {
if s, perr := strconv.Atoi(resp.Header.Get("Retry-After")); perr == nil {
wait = min(time.Duration(s)*time.Second, maxWait)
}
io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
resp.Body.Close()
}
if attempt == attempts {
if err == nil {
err = fmt.Errorf("giving up after %d attempts: status %d", attempt, resp.StatusCode)
}
return nil, err
}
select {
case <-time.After(wait):
case <-ctx.Done():
return nil, context.Cause(ctx)
}
backoff *= 2
}
}
// maxWait caps what a server can ask for; use a few seconds in production.
const maxWait = 30 * time.Millisecond
func retryable(code int) bool {
switch code {
case http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
}
return false
}
func main() {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) < 3 {
w.Header().Set("Retry-After", "1")
http.Error(w, "busy", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "finally")
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := getWithRetry(ctx, srv.Client(), srv.URL, 5)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Printf("%d after %d calls: %s", resp.StatusCode, calls.Load(), b)
}
200 after 3 calls: finally
Source: net/http: Client
HTTP/2 and protocol selection
Over TLS, the client and the server negotiate HTTP/2 through ALPN automatically, with no code. Go 1.24 added the
Protocols field to http.Server and http.Transport (a small set type with SetHTTP1, SetHTTP2 and
SetUnencryptedHTTP2) to make the choice explicit, and HTTP2 (*http.HTTP2Config) to tune HTTP/2 settings such as
stream limits and ping timeouts. Unencrypted HTTP/2 ("prior knowledge", RFC 9113 section 3.3; the Upgrade: h2c form is
not supported) is useful behind a TLS-terminating proxy.
package main
import (
"fmt"
"io"
"net"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "served over %s\n", r.Proto)
})
// One cleartext port speaking both HTTP/1.1 and HTTP/2 (prior knowledge).
srv := &http.Server{Handler: mux, Protocols: new(http.Protocols)}
srv.Protocols.SetHTTP1(true)
srv.Protocols.SetUnencryptedHTTP2(true)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
go srv.Serve(ln)
defer srv.Close()
for _, h2 := range []bool{false, true} {
tr := &http.Transport{Protocols: new(http.Protocols)}
if h2 {
tr.Protocols.SetUnencryptedHTTP2(true) // HTTP/2 only, no HTTP/1
} else {
tr.Protocols.SetHTTP1(true)
}
resp, err := (&http.Client{Transport: tr}).Get("http://" + ln.Addr().String())
if err != nil {
fmt.Println(err)
continue
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("client proto %s: %s", resp.Proto, b)
tr.CloseIdleConnections()
}
}
client proto HTTP/1.1: served over HTTP/1.1
client proto HTTP/2.0: served over HTTP/2.0
Source: net/http: Protocols
A server whose Protocols is nil speaks HTTP/1 and HTTP/2 over TLS and HTTP/1 over plain TCP. Setting a non-nil
TLSNextProto map without an h2 entry is the older way of disabling HTTP/2 and still works; Protocols is the
simpler tool. Go 1.27 adds RFC 9218 priority handling to the HTTP/2 server (disable it with
Server.DisableClientPriority).
The server
Handlers
The whole server side rests on one interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Source: net/http: Handler
Any value with that method is a handler: a struct holding its dependencies (a store, a logger, a template set), or a
function converted with http.HandlerFunc, which is the standard bridge from the interfaces
chapter (a function type can implement an interface). The server calls ServeHTTP once per request, on a goroutine of
its own, so a handler may block and may run concurrently with itself: any state it shares must be safe for concurrent
use (the catalog.MemStore guards its map with an RWMutex for this reason).
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
)
// hello is a Handler with state: a struct with a ServeHTTP method.
type hello struct{ greeting string }
func (h hello) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "%s, %s\n", h.greeting, r.URL.Query().Get("name"))
}
// late shows the order rule: headers, then WriteHeader, then the body.
func late(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "partial body") // implicit WriteHeader(200)
w.Header().Set("X-Too-Late", "1")
w.WriteHeader(http.StatusInternalServerError) // ignored, logged
}
func get(c *http.Client, url string) {
resp, err := c.Get(url)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("%d %q too-late=%q ctype=%q\n", resp.StatusCode, body,
resp.Header.Get("X-Too-Late"), resp.Header.Get("Content-Type"))
}
func main() {
mux := http.NewServeMux()
mux.Handle("GET /hello", hello{greeting: "Hello"})
mux.HandleFunc("GET /late", late)
mux.HandleFunc("GET /sniff", func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "<html><body>hi</body></html>") // no Content-Type set
})
var logs bytes.Buffer
srv := httptest.NewUnstartedServer(mux)
srv.Config.ErrorLog = log.New(&logs, "", 0)
srv.Start()
defer srv.Close()
get(srv.Client(), srv.URL+"/hello?name=Ada")
get(srv.Client(), srv.URL+"/late")
get(srv.Client(), srv.URL+"/sniff")
fmt.Print("server log: ", logs.String())
}
200 "Hello, Ada\n" too-late="" ctype="text/plain; charset=utf-8"
200 "partial body\n" too-late="" ctype="text/plain; charset=utf-8"
200 "<html><body>hi</body></html>" too-late="" ctype="text/html; charset=utf-8"
server log: http: superfluous response.WriteHeader call from main.late (main.go:24)
Source: net/http: Handler
The response has an order, and ResponseWriter enforces it silently:
-
Headers first.
w.Header()returns the header map; changes made after the status line has been sent are ignored (the second request above showsX-Too-Latemissing from the response). -
WriteHeader(code)once. It sends the status line and headers. If the handler never calls it, the firstWritecallsWriteHeader(http.StatusOK)for it. A second call is ignored and logged (superfluous response.WriteHeader call), as the server log line above shows.go vetdoes not catch this; it is a classic source of "my 500 was sent as a 200". -
The body.
Writesends bytes. If noContent-Typewas set, the server sniffs the first 512 bytes withhttp.DetectContentType(the third request:text/html; charset=utf-8). SetContent-Typeexplicitly for anything but trivial text, and for JSON always.
http.Error(w, msg, code) sets Content-Type: text/plain; charset=utf-8 and X-Content-Type-Options: nosniff, writes the
status and the message, and is the right tool for plain-text failures; http.NotFound, http.Redirect and
http.StatusText are its relatives. Use the http.Status… constants (http.StatusCreated, http.StatusNoContent)
instead of numbers. The handler must not touch w or r after ServeHTTP returns, and it must not keep r.Body
open: the server closes the request body for you.
If a handler panics, the server recovers, logs the stack trace to Server.ErrorLog and closes the connection (or
resets the HTTP/2 stream); one bad request does not take the process down. Panicking with http.ErrAbortHandler aborts
the response quietly without the log entry, which is how reverse proxies abandon a response mid-copy. Recovering in
middleware (below) lets you turn the panic into a 500 and log it your way.
The request carries what the client sent:
| Field or method | Meaning |
|---|---|
|
Request line. On a server, |
|
The |
|
Request headers, canonical-key map. |
|
The body stream (never |
|
The query string parsed into |
|
Wildcard values captured by the mux (Go 1.22). |
|
The mux pattern that matched, for logging and metrics labels (Go 1.23). |
|
URL-encoded forms and query parameters; |
|
Cookies sent by the client. |
|
The request’s context. It is cancelled when the client disconnects, when the HTTP/2 stream is reset and when
|
|
The peer’s address ( |
The request context deserves a sentence of its own: it is how a handler learns that the client has gone away, and it is the parent of every context the handler derives for database calls and outgoing HTTP requests. The Context page shows the cancellation chain in detail.
ServeMux patterns
http.NewServeMux() returns the standard router. Until Go 1.21 it matched only a path prefix, which is why older books and
tutorials recommend a third-party router for anything with a method or a variable in the path. Go 1.22 replaced the
matching rules (see the blog post Routing Enhancements for Go 1.22); the old
behaviour can still be restored with GODEBUG=httpmuxgo121=1, but new code never needs it. The pattern grammar is:
[METHOD ][HOST]/[PATH]
| Pattern | Matches | Notes |
|---|---|---|
|
exactly |
Any method. A pattern with no trailing slash and no wildcard matches one path. |
|
|
A |
|
|
Method names are case-sensitive; one or more spaces or tabs may follow the method (Go 1.23). |
|
everything under |
A trailing slash makes a subtree pattern. A request for |
|
exactly |
|
|
|
A |
|
|
A final |
|
|
A host before the first slash restricts the pattern to that host. When a host pattern and a host-less pattern would conflict, the host pattern wins (a backwards-compatibility exception). |
A wildcard must be a whole segment and it must be named with a valid Go identifier: /books/b{x}, /books/{x}y and
/books/{} are rejected, and {name...} and {$} may appear only at the end. A trailing slash in a pattern is
shorthand for an anonymous {...} wildcard. The route table below is a complete mux; the program sends each
request straight to the mux with httptest.NewRecorder, so no listener is needed:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
func main() {
mux := http.NewServeMux()
handle := func(pattern string) {
mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "matched %q", r.Pattern)
for _, name := range []string{"isbn", "rest"} {
if v := r.PathValue(name); v != "" {
fmt.Fprintf(w, " %s=%q", name, v)
}
}
})
}
handle("/{$}")
handle("GET /books")
handle("POST /books")
handle("GET /books/{isbn}")
handle("GET /books/{isbn}/loans")
handle("GET /books/new")
handle("/files/{rest...}")
handle("GET /api/")
handle("books.shelf.test/books")
try := func(method, target string) {
req := httptest.NewRequest(method, target, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req) // the mux is an http.Handler: no network needed
line, _, _ := strings.Cut(rec.Body.String(), "\n")
if allow := rec.Header().Get("Allow"); allow != "" {
line += " (Allow: " + allow + ")"
}
fmt.Printf("%-6s %-34s %d %s\n", method, target, rec.Code, line)
}
try("GET", "/")
try("GET", "/nothing")
try("GET", "/books")
try("POST", "/books")
try("DELETE", "/books")
try("GET", "/books/9780441172719")
try("GET", "/books/new")
try("GET", "/books/9780441172719/loans")
try("GET", "/books/9780441172719/loans/extra")
try("GET", "/files/a/b/c.txt")
try("GET", "/files/")
try("GET", "/api/anything/at/all")
try("HEAD", "/books")
try("GET", "http://books.shelf.test/books")
}
GET / 200 matched "/{$}"
GET /nothing 404 404 page not found
GET /books 200 matched "GET /books"
POST /books 200 matched "POST /books"
DELETE /books 405 Method Not Allowed (Allow: GET, HEAD, POST)
GET /books/9780441172719 200 matched "GET /books/{isbn}" isbn="9780441172719"
GET /books/new 200 matched "GET /books/new"
GET /books/9780441172719/loans 200 matched "GET /books/{isbn}/loans" isbn="9780441172719"
GET /books/9780441172719/loans/extra 404 404 page not found
GET /files/a/b/c.txt 200 matched "/files/{rest...}" rest="a/b/c.txt"
GET /files/ 200 matched "/files/{rest...}"
GET /api/anything/at/all 200 matched "GET /api/"
HEAD /books 200 matched "GET /books"
GET http://books.shelf.test/books 200 matched "books.shelf.test/books"
Source: net/http: ServeMux
Read the output line by line:
-
GET /books/newreaches the/books/newhandler, not/books/{isbn}: the most specific pattern wins, regardless of registration order. Pattern P1 is more specific than P2 when P1 matches a strict subset of the requests P2 matches (/books/newis a subset of/books/{isbn}, which is a subset of/books/); if neither is a subset of the other, the patterns conflict. -
DELETE /booksis405and theAllowheader lists what would have worked (GET, HEAD, POST); a path that no pattern matches under any method is404. Send your own JSON for these by wrapping the mux, not by editing the mux. -
HEAD /bookswas served byGET /books, and the handler can tell withr.Method. -
/files/{rest...}matched/files/with an emptyrest, and/api/(a subtree) matched a path of any depth. -
/books/<isbn>/loans/extrais404: there is no subtree pattern under/books/<isbn>/loans. -
Matching happens on the unescaped segments, and a wildcard value is unescaped too: a request for
/a/b%2FcgivesPathValue("x")equal tob/c. If that matters (it does when the value is used to build a file name), validate it.
Registering two patterns that could match the same request without one being strictly more specific panics at registration time, which turns routing mistakes into start-up failures instead of production surprises. Syntax errors panic too. Run it and see the messages:
package main
import (
"fmt"
"net/http"
"regexp"
)
func try(patterns ...string) {
defer func() {
if r := recover(); r != nil {
msg := fmt.Sprint(r)
msg = regexp.MustCompile(`\(registered at [^)]*\)`).ReplaceAllString(msg, "(registered at main.go)")
fmt.Println("panic:", msg)
}
}()
mux := http.NewServeMux()
for _, p := range patterns {
mux.HandleFunc(p, func(http.ResponseWriter, *http.Request) {})
}
fmt.Println("ok:", patterns)
}
func main() {
try("GET /books/{isbn}", "GET /books/new") // the second is more specific
try("/books/{id}", "/books/{isbn}") // same set of requests
try("GET /books/{id}", "/books/new") // overlap, neither is more specific
try("GET /books", "GET /books") // registered twice
try("GET /books/{id}/{id}") // repeated wildcard name
try("GET /files/{rest...}/more") // ... only as the last segment
try("GET") // no path
try("GET /books/{$}/more") // {$} only at the end
}
ok: [GET /books/{isbn} GET /books/new]
panic: pattern "/books/{isbn}" (registered at main.go) conflicts with pattern "/books/{id}" (registered at main.go):
/books/{isbn} matches the same requests as /books/{id}
panic: pattern "/books/new" (registered at main.go) conflicts with pattern "GET /books/{id}" (registered at main.go):
/books/new matches more methods than GET /books/{id}, but has a more specific path pattern
panic: pattern "GET /books" (registered at main.go) conflicts with pattern "GET /books" (registered at main.go):
GET /books matches the same requests as GET /books
panic: parsing "GET /books/{id}/{id}": at offset 16: duplicate wildcard name "id"
panic: parsing "GET /files/{rest...}/more": at offset 11: {...} wildcard not at end
panic: parsing "GET": at offset 0: host/path missing /
panic: parsing "GET /books/{$}/more": at offset 11: {$} not at end
Source: net/http: ServeMux
The second panic is the interesting one: GET /books/{id} and the method-less /books/new overlap (a POST /books/new
matches only the second, a GET /books/new matches both) and neither dominates the other, so the mux refuses to guess.
Add the method to the literal pattern (GET /books/new) to resolve it.
Trailing slashes and redirects
A subtree pattern (/api/) means a request for /api is not served directly: the mux redirects it to /api/, unless
/api is registered separately. A request
whose path is not clean (// or .. segments) is redirected to the cleaned path. Since Go 1.26 these redirects use
status 307 (Temporary Redirect) instead of 301 (Moved Permanently), and 307, unlike 301, tells clients to repeat the
same method and body. Before Go 1.26 a browser or client could cache a 301 for a path that later became a real route, and
clients typically turned a redirected POST into a GET.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
ok := func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }
mux.HandleFunc("POST /api/", ok) // a subtree: the pattern ends in a slash
do := func(method, target string) {
req := httptest.NewRequest(method, target, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("%-5s %-14s -> %d %s\n", method, target, rec.Code, rec.Header().Get("Location"))
}
do("POST", "/api") // missing trailing slash: redirected, method preserved
do("POST", "/api/")
do("POST", "/api//books") // not clean: redirected to the clean path
do("POST", "/api/a/../b")
}
POST /api -> 307 /api/
POST /api/ -> 200
POST /api//books -> 307 /api/books
POST /api/a/../b -> 307 /api/b
Source: Go 1.26 release notes: net/http
The toolchain confirms the claim: the mux source builds these redirects with http.StatusTemporaryRedirect, and the
Go 1.26 release notes describe the change in the net/http section. The mux also strips the port from the
Host header before host matching, and preserves escaped elements such as %2e and %2f instead of treating them as
separators.
Other mux facts
-
Request.Pattern(Go 1.23) holds the pattern that matched. It is the right label for metrics and logs because it has a bounded set of values, unlike the URL path, which contains IDs. It is set on the request the mux received, so middleware that wraps the mux and keeps the same*http.Requestpointer can read it afternext.ServeHTTPreturns (the logging middleware below does). -
mux.Handle(pattern, handler),mux.HandleFunc(pattern, func)register handlers;mux.Handler(r)returns the handler and pattern that would serve a request, which is handy in tests and for the tools described in Testing. -
Sub-routers are plain composition: mount a whole mux under a prefix with
mux.Handle("/admin/", http.StripPrefix("/admin", adminMux)). -
http.Handleandhttp.HandleFuncregister on the globalhttp.DefaultServeMux. That global is the reason the Go community advises never to use it.
Why not http.DefaultServeMux
A server whose Handler is nil serves http.DefaultServeMux, and any package imported anywhere in the program can
register routes on it in an init function. net/http/pprof does exactly that; importing it "just for the profiler"
exposes the profiling endpoints on every server in the program that uses the default mux:
package main
import (
"fmt"
"net"
"net/http"
_ "net/http/pprof" // registers /debug/pprof/* on http.DefaultServeMux
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
// Handler is nil, so the server uses http.DefaultServeMux.
srv := &http.Server{}
go srv.Serve(ln)
defer srv.Close()
resp, err := http.Get("http://" + ln.Addr().String() + "/debug/pprof/cmdline")
if err != nil {
fmt.Println(err)
return
}
resp.Body.Close()
fmt.Println("a server you never configured answers:", resp.StatusCode)
// The fix: give the server its own mux, and mount diagnostics deliberately.
srv2 := &http.Server{Handler: http.NewServeMux()}
ln2, _ := net.Listen("tcp", "127.0.0.1:0")
go srv2.Serve(ln2)
defer srv2.Close()
resp, err = http.Get("http://" + ln2.Addr().String() + "/debug/pprof/cmdline")
if err != nil {
fmt.Println(err)
return
}
resp.Body.Close()
fmt.Println("a server with its own mux answers:", resp.StatusCode)
}
a server you never configured answers: 200
a server with its own mux answers: 404
Source: net/http/pprof
Always create your own ServeMux (or any Handler), and mount diagnostics deliberately and only on an internal
listener. The same global-state reasoning applies to http.DefaultClient and http.DefaultTransport on the client side.
The shelf JSON API
Here is the shelf catalogue as an HTTP service: three routes, a handler struct holding the catalog.Store interface,
and four small helpers for the repetitive parts. The wire format is a separate bookJSON type, so JSON tags and
field names do not leak into the domain type (the pattern from encoding/json).
The program starts the server with httptest.NewServer and drives it with ten requests, including the failures:
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"example.com/shelf/catalog"
)
// bookJSON is the wire format; catalog.Book stays free of JSON concerns.
type bookJSON struct {
ISBN string `json:"isbn"`
Title string `json:"title"`
Authors []string `json:"authors"`
}
func toJSON(b catalog.Book) bookJSON {
out := bookJSON{ISBN: b.ISBN.String(), Title: b.Title, Authors: []string{}}
for _, a := range b.Authors {
out.Authors = append(out.Authors, a.Name)
}
return out
}
type api struct {
store catalog.Store
}
// routes returns the whole service as one http.Handler.
func (a *api) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /books", a.listBooks)
mux.HandleFunc("POST /books", a.createBook)
mux.HandleFunc("GET /books/{isbn}", a.getBook)
return mux
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// decodeBody reads exactly one JSON value of at most 4 KiB.
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
if _, err := dec.Token(); err != io.EOF {
return errors.New("body must contain a single JSON value")
}
return nil
}
func (a *api) listBooks(w http.ResponseWriter, r *http.Request) {
books := a.store.All()
slices.SortFunc(books, func(x, y catalog.Book) int { return strings.Compare(x.Title, y.Title) })
out := make([]bookJSON, 0, len(books))
for _, b := range books {
out = append(out, toJSON(b))
}
writeJSON(w, http.StatusOK, out)
}
func (a *api) getBook(w http.ResponseWriter, r *http.Request) {
isbn, err := catalog.ParseISBN(r.PathValue("isbn"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
b, ok := a.store.Get(isbn)
if !ok {
writeError(w, http.StatusNotFound, "no such book")
return
}
writeJSON(w, http.StatusOK, toJSON(b))
}
func (a *api) createBook(w http.ResponseWriter, r *http.Request) {
var in bookJSON
if err := decodeBody(w, r, &in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("body larger than %d bytes", tooBig.Limit))
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
isbn, err := catalog.ParseISBN(in.ISBN)
if err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
book := catalog.Book{ISBN: isbn, Title: in.Title}
for _, name := range in.Authors {
book.Authors = append(book.Authors, catalog.Author{Name: name})
}
a.store.Put(book)
w.Header().Set("Location", "/books/"+isbn.String())
writeJSON(w, http.StatusCreated, toJSON(book))
}
func main() {
srv := httptest.NewServer((&api{store: catalog.NewMemStore()}).routes())
defer srv.Close()
c := srv.Client()
do := func(method, path, body string) {
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
loc := ""
if l := resp.Header.Get("Location"); l != "" {
loc = " Location=" + l
}
short := body
if len(short) > 40 {
short = short[:40] + "..."
}
fmt.Printf("%s %s %s\n -> %d%s %s", method, path, short, resp.StatusCode, loc, b)
}
do("POST", "/books", `{"isbn":"978-0-441-17271-9","title":"Dune","authors":["Frank Herbert"]}`)
do("GET", "/books/9780441172719", "")
do("GET", "/books", "")
do("GET", "/books/9780000000002", "")
do("GET", "/books/123", "")
do("POST", "/books", `{"isbn":"978-0-441-17271-9","titel":"typo"}`)
do("POST", "/books", `{"isbn":"978-0-441-17271-9"} {"again":true}`)
do("POST", "/books", `{"isbn":"1234567890123","title":"Bad checksum"}`)
do("POST", "/books", `{"isbn":"978-0-441-17271-9","title":"`+strings.Repeat("x", 5000)+`"}`)
do("DELETE", "/books/9780441172719", "")
}
POST /books {"isbn":"978-0-441-17271-9","title":"Dun...
-> 201 Location=/books/9780441172719 {"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"]}
GET /books/9780441172719
-> 200 {"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"]}
GET /books
-> 200 [{"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"]}]
GET /books/9780000000002
-> 404 {"error":"no such book"}
GET /books/123
-> 400 {"error":"catalog: invalid ISBN: \"123\""}
POST /books {"isbn":"978-0-441-17271-9","titel":"typ...
-> 400 {"error":"json: unknown field \"titel\""}
POST /books {"isbn":"978-0-441-17271-9"} {"again":tr...
-> 400 {"error":"body must contain a single JSON value"}
POST /books {"isbn":"1234567890123","title":"Bad che...
-> 422 {"error":"catalog: invalid ISBN: \"1234567890123\""}
POST /books {"isbn":"978-0-441-17271-9","title":"xxx...
-> 413 {"error":"body larger than 4096 bytes"}
DELETE /books/9780441172719
-> 405 Method Not Allowed
Source: net/http: MaxBytesReader
What the handlers do, and why:
-
routes()returns anhttp.Handler. The program that owns the listener, the middleware and the timeouts is separate from the program that knows the routes, which is what makes the handlers testable without a network (see Testing handlers and servers). -
Status codes mean something.
201 Createdwith aLocationheader for a new resource,404for a missing one,400for malformed input (a bad ISBN in the path, an unknown JSON field, trailing garbage),422 Unprocessable Entity(RFC 9110 now calls it Unprocessable Content) for well-formed input that fails validation (the checksum),413for an oversized body,405from the mux. -
http.MaxBytesReader(w, r.Body, n)wraps the body so that reading more thannbytes fails with*http.MaxBytesError(check it witherrors.As) and, if possible, makes the server close the connection after the response. Without it a client can stream gigabytes intojson.Decoderand the handler dutifully allocates. Put it on every handler that reads a body, or on a whole subtree withhttp.MaxBytesHandler.ParseFormhas its own 10 MiB default cap when no limit was set. -
DisallowUnknownFieldsand the single-value check make typos visible ("titel") and reject a second JSON document, instead of silently accepting half of what the client meant. -
json.NewEncoder(w).Encode(v)writes afterWriteHeader, so the status is already sent when an encoding error could occur. For the small, fixed shapes here that cannot fail; for a large or fallible response, marshal into a buffer first and write only when it succeeded. -
The error body is JSON too (
{"error": "..."}), so clients parse failures the same way they parse successes. A fuller convention for this is RFC 9457 problem details; the standard library has no helper for it.
The routes could be mounted unchanged in a larger program, because everything about the service is behind one
http.Handler. The next sections wrap it, configure the server around it and run it.
Forms and cookies
Not every client sends JSON. HTML forms send application/x-www-form-urlencoded (or multipart/form-data for file
uploads), and sessions travel in cookies. r.ParseForm() merges the query string and the body into r.Form and puts only
the body fields in r.PostForm; r.FormValue(key) calls ParseForm for you but ignores its error, which is why
production code calls ParseForm explicitly, behind MaxBytesReader. For uploads, r.ParseMultipartForm(maxMemory)
keeps at most maxMemory bytes of file parts in memory and spools the rest to temporary files, which you release with
r.MultipartForm.RemoveAll(); r.FormFile(name) returns one file. HTML pages are produced with the
html/template package (see Text and HTML Templates), which
escapes by context, instead of string concatenation.
package main
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
)
func main() {
mux := http.NewServeMux()
// Forms: ParseForm reads the query string and a urlencoded body.
mux.HandleFunc("POST /loans", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Fprintf(w, "isbn=%s member=%s via-query=%s\n",
r.PostFormValue("isbn"), r.PostForm.Get("member"), r.URL.Query().Get("src"))
})
// Cookies: the server sets one, the client jar returns it.
mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "demo-session-id", // a real one is long, random and stored server-side
Path: "/",
HttpOnly: true, // not readable by JavaScript
Secure: false, // true in production (HTTPS only)
SameSite: http.SameSiteLaxMode, // sent on same-site and top-level navigations
MaxAge: 3600,
})
fmt.Fprintln(w, "logged in")
})
mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err != nil { // http.ErrNoCookie
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "hello, session %s\n", c.Value)
})
srv := httptest.NewServer(mux)
defer srv.Close()
show := func(resp *http.Response, err error) {
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Printf("%d %s", resp.StatusCode, b)
}
show(http.PostForm(srv.URL+"/loans?src=web", url.Values{"isbn": {"9780441172719"}, "member": {"ada"}}))
show(http.Post(srv.URL+"/loans", "application/json", strings.NewReader(`{"isbn":"1"}`)))
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
show(client.Get(srv.URL + "/me"))
show(client.Post(srv.URL+"/login", "", nil))
show(client.Get(srv.URL + "/me"))
u, _ := url.Parse(srv.URL)
for _, c := range jar.Cookies(u) {
fmt.Println("jar holds:", c.Name)
}
}
200 isbn=9780441172719 member=ada via-query=web
200 isbn= member= via-query=
401 http: named cookie not present
200 logged in
200 hello, session demo-session-id
jar holds: session
Source: net/http: Cookie
-
A JSON body is not a form:
ParseFormignores the second request’s body, so its fields are empty and no error is reported. CheckContent-Typeyourself when a handler accepts only one format. -
http.SetCookiewrites aSet-Cookieheader. A session cookie wantsHttpOnly(hidden from JavaScript),Secure(HTTPS only; the example is on plain HTTP so it is off there) and aSameSitemode;SameSite=Laxis a useful default, andStrictis stronger. These attributes complement, and do not replace, the protection in CrossOriginProtection. The cookie value in a real system is long, random and looked up server-side, or a signed or encrypted token; see the access and refresh tokens and JWT and JOSE pages for token formats, and browser-based apps for the cookie-versus-token trade-off. -
r.Cookie(name)returnshttp.ErrNoCookie(http: named cookie not present) when the cookie is absent.
Middleware
Cross-cutting behaviour (logging, request IDs, panic recovery, authentication, compression, limits) is written as
middleware: a function that takes a handler and returns a handler, func(http.Handler) http.Handler. Because a
Handler is an interface and HandlerFunc is a function type, a middleware is a closure that captures next and
returns a HandlerFunc; it runs code before calling next.ServeHTTP, after it, or instead of it. No framework is
needed. The standard library has a handful of ready-made ones (http.TimeoutHandler, http.MaxBytesHandler,
http.StripPrefix, http.CrossOriginProtection.Handler), and the pattern for your own looks like this:
package main
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strconv"
"sync/atomic"
)
// Middleware wraps a handler with behaviour that runs before and/or after it.
type Middleware func(http.Handler) http.Handler
// Chain applies middleware so that the first one listed is the outermost.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
type ctxKey struct{}
// RequestID stores a request ID in the context and echoes it in the response.
func RequestID() Middleware {
var n atomic.Int64
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "req-" + strconv.FormatInt(n.Add(1), 10)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id)))
})
}
}
// statusRecorder remembers what the handler wrote.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(p []byte) (int, error) {
if s.status == 0 {
s.status = http.StatusOK
}
n, err := s.ResponseWriter.Write(p)
s.bytes += n
return n, err
}
// Unwrap lets http.ResponseController reach Flusher, deadlines and Hijacker.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
// Logging logs one structured line per request, after the handler returns.
func Logging(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
id, _ := r.Context().Value(ctxKey{}).(string)
log.LogAttrs(r.Context(), slog.LevelInfo, "request",
slog.String("id", id),
slog.String("method", r.Method),
slog.String("pattern", r.Pattern),
slog.Int("status", rec.status),
slog.Int("bytes", rec.bytes))
})
}
}
// Recover turns a panic in a handler into a 500 instead of a dropped connection.
func Recover(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
if v == http.ErrAbortHandler {
panic(v) // deliberate abort: let net/http handle it
}
log.Error("panic in handler", "panic", v)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{} // drop the time to keep the output stable
}
return a
},
}))
mux := http.NewServeMux()
mux.HandleFunc("GET /ping", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "pong")
})
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
panic("catalogue is on fire")
})
// Logging is inside RequestID (it can read the ID) and outside Recover
// (it sees the 500 that Recover writes).
h := Chain(mux, RequestID(), Logging(log), Recover(log))
srv := httptest.NewServer(h)
defer srv.Close()
for _, path := range []string{"/ping", "/boom", "/missing"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
fmt.Println(err)
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%s -> %d %s id=%s\n", path, resp.StatusCode, string(body[:len(body)-1]), resp.Header.Get("X-Request-ID"))
}
}
level=INFO msg=request id=req-1 method=GET pattern="GET /ping" status=200 bytes=5
/ping -> 200 pong id=req-1
level=ERROR msg="panic in handler" panic="catalogue is on fire"
level=INFO msg=request id=req-2 method=GET pattern="GET /boom" status=500 bytes=22
/boom -> 500 internal server error id=req-2
level=INFO msg=request id=req-3 method=GET pattern="" status=404 bytes=19
/missing -> 404 404 page not found id=req-3
Source: net/http: Handler
Points worth knowing:
-
Order is nesting.
Chain(mux, RequestID(), Logging(log), Recover(log))makesRequestIDthe outermost layer andRecoverthe innermost, closest to the mux. A request passes down through the layers and the response comes back up through the same layers. Logging sits outsideRecoverso it sees the 500 thatRecoverwrites; if it sat inside, a panic would unwind through it and skip the log line. Authentication belongs outside the handler it protects and inside logging; compression is outermost on the response path. -
Wrapping
ResponseWriterhides optional interfaces. The server’s writer also implementshttp.Flusher,http.Hijackerand, on HTTP/2, more; a struct that embeds only thehttp.ResponseWriterinterface exposes none of them, sow.(http.Flusher)silently fails behind your wrapper. The fix (Go 1.20) is theUnwrap() http.ResponseWritermethod on the wrapper, whichhttp.ResponseControllerfollows: handlers then callhttp.NewResponseController(w).Flush()and it works through any number of wrappers. Libraries that wrap writers withoutUnwrapare the usual reason streaming "does not work behind the middleware". -
r.WithContext(ctx)and the same pointer. A middleware that adds to the context passes a copy made withr.WithContext(newCtx)tonext; the downstream handlers see the new context. The logging middleware readsr.Patternafter the call because the mux set it on the request value that travelled through the chain (a middleware that passed its own copy of the request tonextwould not see the pattern in its variable). -
Context values use an unexported key type (
ctxKey struct{}), so no other package can collide with or forge the value; see Context for the rules and for why values must stay request-scoped data, not dependencies. -
Duration and size are the useful log fields, and timing is deliberately left out of the output above to keep it stable. Log method, the mux pattern (not the raw path, which has unbounded cardinality), status, duration and bytes; see Structured Logging with slog for handler setup and
LogAttrs. -
Per-route middleware is just wrapping at registration:
mux.Handle("POST /books", requireAuth(http.HandlerFunc(a.createBook))). Group-level wrapping is a sub-mux wrapped once and mounted withStripPrefix. -
Recovering from panics with
recovermust re-panichttp.ErrAbortHandler, and must not try to write a response if the handler had already started one (the status is already sent; closing the connection is the honest outcome).
The route a request takes through a service built like this is:
start one goroutine for this request S->>R: ServeHTTP(w, r) R->>R: store request ID in the context R->>L: next.ServeHTTP(w, r with ID) L->>P: next.ServeHTTP(statusRecorder, r) P->>X: next.ServeHTTP(w, r) X->>X: match pattern GET /books/{isbn},
set r.Pattern and PathValue X->>H: ServeHTTP(w, r) H-->>X: JSON written with status 200 X-->>P: returns P-->>L: returns (a panic here would become a 500) L->>L: log method, pattern, status, bytes L-->>R: returns R-->>S: returns S-->>C: response (WriteTimeout covers handler and write)
Third-party routers and middleware collections (chi, gorilla/mux, alice and others) are packages built on exactly this signature, which is why they interoperate with the standard library. Since Go 1.22 the main reasons to adopt a router (methods and path variables) are gone from the standard library; see Alternatives and frameworks.
CrossOriginProtection
Cross-site request forgery (CSRF) tricks a user’s browser into sending a state-changing request to your site while the
browser attaches the user’s cookies. Go 1.25 added http.CrossOriginProtection, a middleware that defends against it
without tokens or cookies, by using the browser’s fetch metadata: every modern browser (all since 2023) sends a
Sec-Fetch-Site header on requests, saying whether the request came from the same origin, the same site, another site
or directly from the user (none).
The check, in the order the implementation applies it:
-
GET,HEADandOPTIONSare always allowed. They are the safe methods; this is why aGEThandler must never change state. -
If
Sec-Fetch-Siteissame-originornone, the request is allowed. If it is any other value (same-siteandcross-siteincluded), the request is allowed only when itsOriginis a trusted origin or it matches a bypass pattern; otherwise it is rejected with403 Forbidden. -
If the header is absent (an old browser, or not a browser), the
Originheader is compared withHost: equal hosts are allowed, a different host is rejected unless trusted or bypassed. -
A request with neither header is assumed to be same-origin or a non-browser client (curl, a mobile app, another server) and is allowed: CSRF is a browser problem, and non-browser clients do not carry ambient cookies.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /loans", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "loan created")
})
mux.HandleFunc("GET /loans", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "loan list")
})
mux.HandleFunc("POST /webhooks/payments", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "webhook accepted")
})
cop := http.NewCrossOriginProtection()
if err := cop.AddTrustedOrigin("https://admin.shelf.example"); err != nil {
panic(err)
}
// Server-to-server callbacks are not browser requests; authenticate them
// by signature instead (see the OAuth pages for token-based options).
cop.AddInsecureBypassPattern("POST /webhooks/payments")
h := cop.Handler(mux)
try := func(label, method, target string, hdr map[string]string) {
req := httptest.NewRequest(method, target, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Printf("%-34s %d %s", label, rec.Code, rec.Body.String())
}
try("curl, no browser headers", "POST", "/loans", nil)
try("same-origin form post", "POST", "/loans", map[string]string{"Sec-Fetch-Site": "same-origin"})
try("cross-site form post", "POST", "/loans", map[string]string{"Sec-Fetch-Site": "cross-site"})
try("cross-site GET (safe method)", "GET", "/loans", map[string]string{"Sec-Fetch-Site": "cross-site"})
try("old browser, other Origin", "POST", "/loans", map[string]string{"Origin": "https://evil.example"})
try("old browser, same Origin", "POST", "/loans", map[string]string{"Origin": "http://example.com"})
try("trusted admin origin", "POST", "/loans", map[string]string{"Origin": "https://admin.shelf.example", "Sec-Fetch-Site": "cross-site"})
try("bypass pattern, cross-site", "POST", "/webhooks/payments", map[string]string{"Sec-Fetch-Site": "cross-site"})
err := cop.Check(func() *http.Request {
r := httptest.NewRequest("POST", "/loans", nil)
r.Header.Set("Sec-Fetch-Site", "cross-site")
return r
}())
fmt.Println("Check:", err)
}
curl, no browser headers 200 loan created
same-origin form post 200 loan created
cross-site form post 403 cross-origin request detected from Sec-Fetch-Site header
cross-site GET (safe method) 200 loan list
old browser, other Origin 403 cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match Host
old browser, same Origin 200 loan created
trusted admin origin 200 loan created
bypass pattern, cross-site 200 webhook accepted
Check: cross-origin request detected from Sec-Fetch-Site header
Source: net/http: CrossOriginProtection
-
http.NewCrossOriginProtection()returns the middleware; the zero value is also valid and has no trusted origins. Wrap the whole mux withcop.Handler(mux), or callcop.Check(r)yourself when you want a custom failure path; the default response is403with a short text message, andSetDenyHandlerreplaces it (Checknever calls the deny handler). -
AddTrustedOrigin("https://admin.shelf.example")lets a specific other origin (exact scheme, host and port) call the service, for example a separately hosted front end.AddInsecureBypassPattern("POST /webhooks/payments")exempts routes that are not meant for browsers, such as webhooks authenticated by a signature. The bypass pattern uses theServeMuxsyntax and precedence rules and matches only requests that reach the pattern directly (not those the mux would redirect). The word "insecure" is the library’s own warning. -
It is CSRF protection, not CORS. CORS (cross-origin resource sharing) is the browser’s read policy for cross-origin responses, controlled by
Access-Control-*response headers; it never stops a request from being sent.CrossOriginProtectionstops the dangerous cross-origin writes from being processed. A service that is called from another origin’s JavaScript needs both ideas: the trusted-origin list here for writes, and CORS headers for reads. The site’s CORS page explains the browser mechanism, and the CSRF and CORS sections of Axum CSRF and security hardening and NestJS security headers, CORS and CSRF compare the same defences in other stacks. The standard library does not implement CORS headers; write a small middleware or use a maintained package. -
It protects cookie-authenticated sessions, where the browser adds credentials automatically. APIs that authenticate with an
Authorization: Bearerheader the browser does not add by itself are not CSRF-prone in the same way; the token formats and where to keep them are in OAuth for browser-based apps and the OAuth security best practices. -
Before Go 1.25 the same protection needed a token library (gorilla/csrf and similar) with per-session secrets and hidden form fields. Those are still the answer for old browsers that must be supported and for sites that need token binding.
Configuring http.Server
http.ListenAndServe(addr, handler) creates a Server with every field zero. That server has no timeouts at all: a
client that opens a connection and sends one byte a minute holds a goroutine and a file descriptor indefinitely (the
"Slowloris" attack, and also what a flaky mobile network does by accident). A production server is configured
explicitly:
| Field | Meaning |
|---|---|
|
|
|
The root handler. |
|
Time allowed to read the request line and headers. Set it on every server; it is the cheapest defence against slow
clients. Zero falls back to |
|
Maximum duration for reading the entire request, body included. A blunt limit: it cannot be adjusted per handler
except through |
|
Time from the end of reading the request headers to the end of writing the response. It includes the handler’s processing time, so it must exceed your slowest legitimate response. |
|
How long a keep-alive connection waits for the next request. Zero falls back to |
|
Limit on the request line and headers (default |
|
Limit on the number of header values (default |
|
A |
|
Callback on connection state changes ( |
|
Derive the root context of all requests ( |
|
TLS settings for |
|
Protocol selection and HTTP/2 tuning (Go 1.24, 1.27). |
|
Pass |
Pick the numbers from your traffic: a few seconds for ReadHeaderTimeout, tens of seconds for ReadTimeout and
WriteTimeout unless the service moves large bodies, a minute or two for IdleTimeout. The figure shows which parts of
one request each of them covers.
The behaviour is easiest to believe when it is observed. The program below sets deliberately tiny timeouts and shows a
stalled client being dropped, a handler that outlives WriteTimeout, the same slow handler succeeding after it extends
its own deadline with ResponseController, and an idle keep-alive connection being closed:
package main
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /fast", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "fast")
})
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(600 * time.Millisecond) // longer than WriteTimeout
fmt.Fprintln(w, "slow but finished")
})
mux.HandleFunc("GET /export", func(w http.ResponseWriter, r *http.Request) {
// This one endpoint is allowed more time than the server default.
rc := http.NewResponseController(w)
if err := rc.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
time.Sleep(600 * time.Millisecond)
fmt.Fprintln(w, "export finished")
})
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 200 * time.Millisecond,
WriteTimeout: 300 * time.Millisecond,
IdleTimeout: 300 * time.Millisecond,
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
go srv.Serve(ln)
defer srv.Close()
addr := ln.Addr().String()
// 1. ReadHeaderTimeout: a client that sends half a request line and stalls.
conn, _ := net.Dial("tcp", addr)
fmt.Fprint(conn, "GET /fast HTTP/1.1\r\nHost: x\r\nX-Slow: ")
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
reply, err := io.ReadAll(conn)
conn.Close()
fmt.Printf("stalled header: %d bytes received, connection closed by server: %t\n", len(reply), err == nil)
// 2. WriteTimeout: the handler outlives the write deadline.
for _, path := range []string{"/fast", "/slow", "/export"} {
resp, err := http.Get("http://" + addr + path)
if err != nil {
fmt.Printf("GET %-7s error: %v\n", path, strings.TrimPrefix(err.Error(), `Get "http://`+addr+path+`": `))
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("GET %-7s %d %q\n", path, resp.StatusCode, body)
}
// 3. IdleTimeout: an idle keep-alive connection is closed by the server.
conn, _ = net.Dial("tcp", addr)
fmt.Fprint(conn, "GET /fast HTTP/1.1\r\nHost: x\r\n\r\n")
br := bufio.NewReader(conn)
resp, _ := http.ReadResponse(br, nil)
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, err = br.ReadByte()
fmt.Printf("idle connection: first response %d, then %v\n", resp.StatusCode, err)
conn.Close()
}
stalled header: 0 bytes received, connection closed by server: true
GET /fast 200 "fast\n"
GET /slow error: EOF
GET /export 200 "export finished\n"
idle connection: first response 200, then EOF
Source: net/http: Server
-
A client that sends part of a header line and stops is cut off after
ReadHeaderTimeoutwithout a response. -
/slowsleeps 600 ms butWriteTimeoutis 300 ms: the server closes the connection when the handler finally writes, and the client seesEOFand no response. A write timeout does not cancel the handler, which keeps running until it returns; onlyhttp.TimeoutHandler(below) and the request context do that. -
/exportcallsrc.SetWriteDeadline(...)first and finishes.ResponseController(Go 1.20) is the way to give one endpoint a longer (or shorter) deadline than the server default, withSetReadDeadlinefor slow uploads. It returnshttp.ErrNotSupported(compare witherrors.Is) when the connection cannot do it, such as through a wrapper that lacksUnwrap. -
The idle connection delivers the first response and is then closed by the server after
IdleTimeout.
A timeout that cancels the work is a different tool from a connection deadline. http.TimeoutHandler(h, d, msg) runs h
with a context that expires after d and writes a 503 Service Unavailable with msg if h has not finished. Its
caveats: the wrapped handler runs with a buffered writer, so it cannot flush or hijack (no streaming), and a handler that
ignores its context keeps running after the 503 was sent. The same program below shows it, ResponseController.Flush on
a server-sent events stream and http.MaxBytesHandler:
package main
import (
"bufio"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"time"
)
func main() {
mux := http.NewServeMux()
// Server-Sent Events: flush after every event so the client sees it now.
mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
rc := http.NewResponseController(w)
for i := 1; i <= 3; i++ {
select {
case <-r.Context().Done(): // client went away
return
case <-time.After(20 * time.Millisecond):
}
fmt.Fprintf(w, "event: loan\ndata: book %d lent\n\n", i)
if err := rc.Flush(); err != nil {
return // the writer cannot flush (wrapped without Unwrap)
}
}
})
// TimeoutHandler: the whole handler gets 100 ms, then the client sees a 503.
slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done(): // TimeoutHandler cancels the context
case <-time.After(2 * time.Second):
}
})
mux.Handle("GET /report", http.TimeoutHandler(slow, 100*time.Millisecond, "report took too long\n"))
// MaxBytesHandler: a limit for the whole subtree, applied before any handler.
upload := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n, err := io.Copy(io.Discard, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
return
}
fmt.Fprintf(w, "read %d bytes\n", n)
})
mux.Handle("POST /import", http.MaxBytesHandler(upload, 16))
srv := httptest.NewServer(mux)
defer srv.Close()
resp, _ := http.Get(srv.URL + "/events")
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
if sc.Text() != "" {
fmt.Println("sse:", sc.Text())
}
}
resp.Body.Close()
resp, _ = http.Get(srv.URL + "/report")
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("report: %d %q\n", resp.StatusCode, b)
for _, size := range []int{10, 100} {
resp, _ = http.Post(srv.URL+"/import", "text/plain", strings.NewReader(strings.Repeat("x", size)))
b, _ = io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("import %3d bytes: %d %s", size, resp.StatusCode, b)
}
}
sse: event: loan
sse: data: book 1 lent
sse: event: loan
sse: data: book 2 lent
sse: event: loan
sse: data: book 3 lent
report: 503 "report took too long\n"
import 10 bytes: 200 read 10 bytes
import 100 bytes: 413 http: request body too large
Source: net/http: TimeoutHandler
Server-sent events need only three things: the text/event-stream content type, event: and data: lines ending in a
blank line, and a flush after each event. A handler that streams must watch r.Context().Done(), because that is the
only signal that the client left. ResponseController also offers Hijack (take over the raw connection, for protocols
such as WebSocket, after which the server no longer manages it) and EnableFullDuplex (Go 1.21: read the HTTP/1 request
body while writing the response, which the server otherwise forbids by discarding the unread body once you start
writing).
Graceful shutdown
When a deployment replaces a process, or when Kubernetes sends SIGTERM, a server that just exits drops every request in
flight. Server.Shutdown(ctx) is the alternative: it closes the listeners, so no new connections arrive, closes idle
connections and waits for active ones to become idle, returning when all are done or when ctx expires. The program
below arranges the pieces:
-
signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)returns a context that is cancelled onCtrl+CorSIGTERM, replacing the older "buffered channel ofos.Signal`" boilerplate. The `stopfunction restores default signal behaviour, so a secondCtrl+Cterminates immediately. -
Serveruns in a goroutine. It returnshttp.ErrServerClosedimmediately whenShutdownis called, which is the expected case and not an error; the main goroutine must wait forShutdownitself to return, not forServe. -
The shutdown context has its own timeout, derived from
context.Background()and not from the cancelled signal context, which is already done. If the deadline passes,Shutdownreturns the context’s error; callingClosethen drops the remaining connections.
package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// run serves h on ln until ctx is cancelled, then drains in-flight requests.
func run(ctx context.Context, ln net.Listener, h http.Handler) error {
srv := &http.Server{
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
errc := make(chan error, 1)
go func() {
// Serve returns ErrServerClosed as soon as Shutdown is called.
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
errc <- err
return
}
errc <- nil
}()
select {
case err := <-errc:
return err // the listener failed
case <-ctx.Done():
}
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
srv.Close() // deadline passed: drop whatever is left
return fmt.Errorf("graceful shutdown failed: %w", err)
}
return <-errc
}
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
},
})))
// SIGINT and SIGTERM cancel ctx; stop() restores default signal handling.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
started := make(chan struct{})
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
close(started)
time.Sleep(300 * time.Millisecond)
fmt.Fprintln(w, "finished the in-flight request")
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
slog.Error("listen", "err", err)
os.Exit(1)
}
done := make(chan error, 1)
go func() { done <- run(ctx, ln, mux) }()
// Demo client: start a request, then "press Ctrl+C" while it is in flight.
got := make(chan string, 1)
go func() {
resp, err := http.Get("http://" + ln.Addr().String() + "/slow")
if err != nil {
got <- "client error: " + err.Error()
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
got <- fmt.Sprintf("client got: %d %s", resp.StatusCode, b)
}()
<-started
_ = syscall.Kill(os.Getpid(), syscall.SIGINT)
fmt.Println("run returned:", <-done)
fmt.Print(<-got)
_, err = http.Get("http://" + ln.Addr().String() + "/slow")
fmt.Println("after shutdown, new connections fail:", err != nil)
}
level=INFO msg="shutting down"
run returned: <nil>
client got: 200 finished the in-flight request
after shutdown, new connections fail: true
Source: net/http: Server.Shutdown
The demo sends itself SIGINT (with syscall.Kill, which exists on Unix; on Windows use os.Interrupt handling and a
different trigger) while a 300 ms request is in flight. The client still gets its 200: shutdown waited. New connections
after shutdown fail.
Details that bite in production:
-
Shutdowndoes not wait for hijacked connections (WebSockets) and does not cancel request contexts. Long-lived handlers (streams, long polls) must be told to stop: derive their context from a server-wide context that you cancel before callingShutdown(setBaseContext), or register a cleanup withsrv.RegisterOnShutdown(func()), which runs in its own goroutine when shutdown starts. -
Setting
BaseContextto the signal context cancels every in-flight request the moment the signal arrives, which is the opposite of graceful. Give it a context that you cancel after the drain deadline, or one that you cancel to end streams only. -
A service behind a load balancer first fails its readiness probe (so no new requests arrive), waits a moment, then calls
Shutdown; the orchestrator’s grace period (terminationGracePeriodSeconds) must be longer than your shutdown timeout. The sequence and the endpoint-removal race are explained on the health probes and lifecycle page, and the Rust version of the same structure is on the Axum graceful shutdown page. -
Return a non-zero exit code when the server failed to start or to drain, and flush logs and telemetry after
Shutdown.
The shelfd program
cmd/shelfd puts the pieces together: the api from earlier (it lives in api.go in the same package, unchanged from the
shelf API listing), the CSRF protection around the router, explicit timeouts, configuration from the environment and
graceful shutdown. The run function returns an error so that main has one place to log it and choose the exit code.
package main
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"example.com/shelf/catalog"
)
func main() {
if err := run(); err != nil {
slog.Error("shelfd failed", "err", err)
os.Exit(1)
}
}
func run() error {
// Configuration comes from the environment, never from source code.
addr := os.Getenv("SHELF_ADDR")
if addr == "" {
addr = "127.0.0.1:8080"
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
store := catalog.NewMemStore()
isbn, _ := catalog.ParseISBN("978-0-441-17271-9")
store.Put(catalog.Book{ISBN: isbn, Title: "Dune", Authors: []catalog.Author{{Name: "Frank Herbert"}}})
// Reject cross-site browser writes before they reach any handler.
handler := http.NewCrossOriginProtection().Handler((&api{store: store}).routes())
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 64 << 10,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
slog.Info("listening", "addr", ln.Addr().String())
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve(ln) }()
select {
case err := <-serveErr:
return err // Serve only returns on failure here
case <-ctx.Done():
stop() // a second Ctrl+C now kills the process
}
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
_ = srv.Close()
return err
}
if err := <-serveErr; !errors.Is(err, http.ErrServerClosed) {
return err
}
slog.Info("stopped")
return nil
}
Running the compiled binary, calling it with curl and stopping it with SIGTERM (the address comes from the environment
variable, and is a loopback port chosen for the demonstration):
$ SHELF_ADDR=127.0.0.1:18080 ./shelfd &
$ curl -s -i -X POST -H 'Sec-Fetch-Site: cross-site' -d '{}' http://127.0.0.1:18080/books
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 57
cross-origin request detected from Sec-Fetch-Site header
$ kill -TERM %1
and the log it wrote:
2026/10/18 02:29:48 INFO listening addr=127.0.0.1:18081
2026/10/18 02:29:49 INFO shutting down
2026/10/18 02:29:49 INFO stopped
Source: os/signal: NotifyContext
Serving static files
http.FileServerFS(fsys) returns a handler serving an fs.FS, and http.ServeFileFS(w, r, fsys, name) serves one file;
both set Content-Type from the extension, honour If-Modified-Since and Range requests, and redirect index.html.
With //go:embed the files ship inside the binary, which makes the deployment one file (see
Directives, Code Generation and Embedding and
I/O, Files and the Filesystem). FileServerFS,
ServeFileFS and NewFileTransportFS arrived in Go 1.22; before that http.FileServer(http.FS(fsys)) and
http.Dir were the only options, and http.Dir serves the operating-system file system, which is why it deserves care.
The embedded tree for this program is assets/index.html, assets/shelf.css, assets/covers/dune.txt and a dot file
assets/.notes.txt:
package main
import (
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"path"
"strings"
)
//go:embed all:assets
var assets embed.FS
func main() {
// Serve the files at /assets/ without the "assets" directory prefix of
// the embedded tree being doubled.
mux := http.NewServeMux()
mux.Handle("GET /assets/", http.FileServerFS(assets))
// The same tree, but only some files and no directory listings.
sub, err := fs.Sub(assets, "assets")
if err != nil {
panic(err)
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(noDirs{sub})))
// One file with ServeFileFS (never put a request path into the name).
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, assets, "assets/index.html")
})
srv := httptest.NewServer(mux)
defer srv.Close()
for _, p := range []string{"/", "/assets/shelf.css", "/assets/covers/", "/static/covers/", "/static/covers/dune.txt", "/assets/.notes.txt", "/static/.notes.txt", "/static/"} {
resp, err := http.Get(srv.URL + p)
if err != nil {
fmt.Println(err)
continue
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
first, _, _ := strings.Cut(strings.TrimSpace(string(b)), "\n")
if strings.Contains(string(b), "<pre>") {
first = "(directory listing)"
}
fmt.Printf("%-20s %d %-24s %s\n", p, resp.StatusCode, resp.Header.Get("Content-Type"), first)
}
}
// noDirs hides directory listings and dot files behind a 404.
type noDirs struct{ fs fs.FS }
func (n noDirs) Open(name string) (fs.File, error) {
if base := name[strings.LastIndex(name, "/")+1:]; base != "." && strings.HasPrefix(base, ".") {
return nil, fs.ErrNotExist
}
f, err := n.fs.Open(name)
if err != nil {
return nil, err
}
if st, err := f.Stat(); err == nil && st.IsDir() {
// Allow a directory only if it holds an index.html.
idx, err := n.fs.Open(path.Join(name, "index.html"))
if err != nil {
f.Close()
return nil, fs.ErrNotExist
}
idx.Close()
}
return f, nil
}
/ 200 text/html; charset=utf-8 <!doctype html>
/assets/shelf.css 200 text/css; charset=utf-8 body { font-family: sans-serif; }
/assets/covers/ 200 text/html; charset=utf-8 (directory listing)
/static/covers/ 404 text/plain; charset=utf-8 404 page not found
/static/covers/dune.txt 200 text/plain; charset=utf-8 cover art placeholder
/assets/.notes.txt 200 text/plain; charset=utf-8 internal notes
/static/.notes.txt 404 text/plain; charset=utf-8 404 page not found
/static/ 200 text/html; charset=utf-8 <!doctype html>
Source: net/http: FileServerFS
-
//go:embed assetswould silently skip files whose names begin with.or_;all:assetsincludes them, which is what makes the dot-file demonstration meaningful (the “all:” prefix is covered on the embedding page). -
FileServerFS(assets)mounted at/assets/servesassets/shelf.cssat/assets/shelf.cssbecause the embedded tree contains the directory name, but it also serves directory listings (/assets/covers/returned a listing) and dot files (/assets/.notes.txtreturned 200). That is the default and it is rarely what you want. -
fs.Sub(assets, "assets")re-roots the tree, andhttp.StripPrefix("/static/", ...)removes the URL prefix so the server seesshelf.css. Wrapping thefs.FSin a type whoseOpenrefuses directories without anindex.htmland names starting with a dot returns404for exactly those cases. -
Never pass a request-controlled path to
http.ServeFileorhttp.ServeFileFS. They refuse a request whoseURL.Pathcontains a..element as a precaution, but the name is still yours to validate: pass a constant, or a name you checked against a list.os.Root(Go 1.24) confines file operations to a directory and is the tool for files that come from a tree users can influence; it is covered with the other hardening measures on Crypto and Security. -
http.ServeContent(w, r, name, modtime, io.ReadSeeker)serves content that is not in a file system, with conditional and range support. Since Go 1.23 theServe*functions removeCache-Control,Content-Encoding,EtagandLast-Modifiedheaders when they serve an error (setGODEBUG=httpservecontentkeepheaders=1to restore the old behaviour), which breaks middleware that compresses on the fly withContent-Encoding.
Reverse proxies
httputil.ReverseProxy forwards requests to another server and copies the response back, and is a handler like any other.
The configuration hook is Rewrite, which receives the inbound request and the outbound request being built (SetURL
routes to a target, SetXForwarded adds the forwarding headers, and header edits go on r.Out). It was added in Go 1.20
because the original Director hook cannot be made safe: a malicious client can name a header added by Director in its
Connection header and have the proxy strip it. Director is deprecated since Go 1.26.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
)
func main() {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "backend saw %s %s, X-Forwarded-For set: %t, X-Secret present: %t\n",
r.Method, r.URL.Path, r.Header.Get("X-Forwarded-For") != "", r.Header.Get("X-Secret") != "")
}))
defer backend.Close()
target, _ := url.Parse(backend.URL)
// Rewrite replaces the deprecated Director hook (Go 1.20 added it,
// Go 1.26 deprecated Director).
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target) // route to the backend, joining the paths
r.SetXForwarded() // add X-Forwarded-For, -Host and -Proto
r.Out.Header.Del("X-Secret") // never forward internal headers
},
}
front := httptest.NewServer(proxy)
defer front.Close()
req, _ := http.NewRequest(http.MethodGet, front.URL+"/books", nil)
req.Header.Set("X-Secret", "internal-only")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Print(string(b))
}
backend saw GET /books, X-Forwarded-For set: true, X-Secret present: false
Source: net/http/httputil: ReverseProxy
Testing handlers and servers
Because a handler is an interface, most HTTP tests need no network. net/http/httptest supplies the pieces:
| Tool | Use |
|---|---|
|
Builds an incoming request for a handler. It panics on a malformed target (it is meant for tests), sets
|
|
A |
|
A real server on a loopback port, for testing clients and the full stack. |
|
The same over TLS with a self-signed certificate; |
|
Returns a server you can configure ( |
|
Go 1.27: a server on an in-memory network that registers its own cleanup, fails the test if the handler panics, and works
inside a |
The first test below calls the router directly through the recorder (fast, no ports), the second goes through a whole
server with the Go 1.27 in-memory network, and the third combines NewTestServer with testing/synctest so that a
30-second client timeout is tested in no real time, because the bubble’s clock is fake and advances when every goroutine
is blocked (see Benchmarks, Fuzzing and synctest).
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/synctest"
"time"
"example.com/shelf/catalog"
)
func newAPI(t *testing.T) http.Handler {
t.Helper()
return (&api{store: catalog.NewMemStore()}).routes()
}
// Handler tests: no network, the recorder implements ResponseWriter.
func TestAPI(t *testing.T) {
h := newAPI(t)
tests := []struct {
name string
method string
target string
body string
want int
}{
{"create", "POST", "/books", `{"isbn":"9780441172719","title":"Dune"}`, http.StatusCreated},
{"get", "GET", "/books/9780441172719", "", http.StatusOK},
{"missing", "GET", "/books/9780000000002", "", http.StatusNotFound},
{"bad isbn", "GET", "/books/123", "", http.StatusBadRequest},
{"unknown field", "POST", "/books", `{"isbn":"9780441172719","x":1}`, http.StatusBadRequest},
{"wrong method", "PUT", "/books", "", http.StatusMethodNotAllowed},
}
// The cases share state (create before get), so they run in order.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tt.want {
t.Errorf("%s %s = %d, want %d (body %q)", tt.method, tt.target, rec.Code, tt.want, rec.Body)
}
})
}
}
// End to end over the in-memory network of httptest.NewTestServer.
func TestServer(t *testing.T) {
srv := httptest.NewTestServer(t, newAPI(t))
resp, err := srv.Client().Get("http://shelf.test/books")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q", got)
}
}
// With synctest the clock is fake: a 30 s wait costs no real time.
func TestClientTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(time.Minute):
case <-r.Context().Done():
}
})
srv := httptest.NewTestServer(t, slow)
client := srv.Client()
client.Timeout = 30 * time.Second
_, err := client.Get("http://shelf.test/slow")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want a deadline error", err)
}
})
}
=== RUN TestAPI
=== RUN TestAPI/create
=== RUN TestAPI/get
=== RUN TestAPI/missing
=== RUN TestAPI/bad_isbn
=== RUN TestAPI/unknown_field
=== RUN TestAPI/wrong_method
--- PASS: TestAPI (0.00s)
--- PASS: TestAPI/create (0.00s)
--- PASS: TestAPI/get (0.00s)
--- PASS: TestAPI/missing (0.00s)
--- PASS: TestAPI/bad_isbn (0.00s)
--- PASS: TestAPI/unknown_field (0.00s)
--- PASS: TestAPI/wrong_method (0.00s)
=== RUN TestServer
--- PASS: TestServer (0.00s)
=== RUN TestClientTimeout
--- PASS: TestClientTimeout (0.00s)
PASS
ok example.com/shelf/cmd/shelfd 0.275s
Source: net/http/httptest: NewTestServer
-
Test the
http.Handler(routes()), notmain. This is the payoff of the structure shown earlier. -
The test table runs its cases in order because they share the store; subtests that must be independent build their own handler.
-
For a client under test, point it at an
httptest.Server: the client code is exercised for real (headers, status, redirects, timeouts) and the fake server’s handler decides what happens. A server that returns503twice and then200is a few lines of test for the retry helper above (the retry example above was run against exactly such a server). -
NewServerlistens on127.0.0.1and leaves the port to the operating system, so tests do not collide; the in-memoryNewTestServeravoids ports altogether and, since it needs no sleeps, makes timeout behaviour deterministic. Before Go 1.27 the equivalent wasNewServerplus real sleeps, or a customRoundTripper. -
Race detection (
go test -race) matters for handlers: they run concurrently by design, and the sharedMemStoreis exactly where an unguarded map would be found. See Testing. -
Go 1.26 changed the client returned by the loopback
Server.Client(): it also redirectsexample.comand its subdomains to the test server.
Raw TCP, addresses and URLs
Below HTTP is the net package, and one level of it is enough for most programs that are not HTTP services: a TCP
server or client, deadlines, and the address and URL types. (Note that a hand-written HTTP/1.1 parser is never the answer:
use net/http.) net.Listen("tcp", addr) returns a net.Listener; Accept returns a net.Conn, which is an
io.ReadWriteCloser with deadlines. The idiom is one goroutine per connection, since goroutines are cheap, and a
deadline renewed before each read, since deadlines are absolute times and not timeouts:
package main
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"os"
"strings"
"syscall"
"time"
)
// serve accepts connections until ln is closed; one goroutine per connection.
func serve(ln net.Listener) {
for {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
fmt.Println("accept:", err)
return
}
go handle(conn)
}
}
// handle answers "ISBN <n>" lines with an upper-cased echo; idle clients are dropped.
func handle(conn net.Conn) {
defer conn.Close()
sc := bufio.NewScanner(conn)
for {
// A deadline is absolute and must be renewed for every read.
conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
if !sc.Scan() {
return // EOF, error or deadline
}
fmt.Fprintf(conn, "ECHO %s\n", strings.ToUpper(sc.Text()))
}
}
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0") // port 0: the OS picks a free port
if err != nil {
panic(err)
}
defer ln.Close()
go serve(ln)
// The dialer carries the connect timeout and honours the context.
d := net.Dialer{Timeout: time.Second}
conn, err := d.DialContext(context.Background(), "tcp", ln.Addr().String())
if err != nil {
panic(err)
}
defer conn.Close()
fmt.Println("local and remote are loopback:", strings.HasPrefix(conn.LocalAddr().String(), "127.0.0.1:"), strings.HasPrefix(conn.RemoteAddr().String(), "127.0.0.1:"))
rd := bufio.NewReader(conn)
for _, line := range []string{"dune", "neuromancer"} {
fmt.Fprintln(conn, line)
reply, _ := rd.ReadString('\n')
fmt.Print(reply)
}
// Stay silent: the server's read deadline closes the connection.
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = rd.ReadString('\n')
fmt.Println("after idling:", err)
// A deadline that expires on our side is reported as a timeout.
conn2, _ := net.Dial("tcp", ln.Addr().String())
defer conn2.Close()
conn2.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
_, err = bufio.NewReader(conn2).ReadByte()
var ne net.Error
fmt.Println("local deadline:", errors.Is(err, os.ErrDeadlineExceeded), errors.As(err, &ne) && ne.Timeout())
// Connecting where nobody listens fails fast.
ln.Close()
_, err = d.Dial("tcp", ln.Addr().String())
var op *net.OpError
fmt.Println("refused:", errors.As(err, &op), op.Op, errors.Is(err, syscall.ECONNREFUSED))
}
local and remote are loopback: true true
ECHO DUNE
ECHO NEUROMANCER
after idling: EOF
local deadline: true true
refused: true dial true
Source: net
-
net.Listen("tcp", "127.0.0.1:0")asks the OS for a free port; read it back withln.Addr(). Binding:8080listens on every interface; bind a loopback address for anything internal. -
net.Dialer{Timeout: ...}.DialContext(ctx, ...)replaces the barenet.Dial, which has no timeout. The dialer also resolves names, tries every address a name resolves to and implements "happy eyeballs" (IPv4 and IPv6 in parallel). -
Conn.SetReadDeadline,SetWriteDeadlineandSetDeadlinetake an absolutetime.Time; set a fresh one for each operation, or one deadline for a whole exchange. An expired deadline produces an error that satisfieserrors.Is(err, os.ErrDeadlineExceeded)andnet.Error.Timeout(), and leaves the connection usable if you extend the deadline. A zerotime.Timeclears it. -
The accept loop ends when the listener is closed;
errors.Is(err, net.ErrClosed)distinguishes that from a real failure. HandleAccepterrors that are temporary (too many open files) by logging and backing off, not by exiting. -
A refused connection is a
*net.OpErrorwrappingsyscall.ECONNREFUSED;errors.Asanderrors.Isreach both. -
TCP is a byte stream, not a message stream: one
Writedoes not equal oneRead. Frame your protocol (lines, length prefixes) and usebufioto read it, as the example does withbufio.Scannerandbufio.Reader. -
net.ListenUDP,net.DialUDPandnet.ListenPacketgive packet sockets,net.Pipean in-memory synchronousConnfor tests, andnet.LookupHost,LookupIPandResolverdo DNS. The Unix domain sockets are"unix"networks. For anything above raw TCP,crypto/tls.Listenandtls.Dialwrap a connection in TLS; see Crypto and Security.
The two helper packages that every network program touches are net/url and net/netip:
package main
import (
"fmt"
"net"
"net/netip"
"net/url"
)
func main() {
// net/url: parse, inspect, build.
u, err := url.Parse("https://reader:example-only@shelf.example:8443/books/search?q=dune&q=herbert&limit=10#top")
if err != nil {
panic(err)
}
fmt.Println("scheme:", u.Scheme, "host:", u.Host, "hostname:", u.Hostname(), "port:", u.Port())
fmt.Println("path:", u.Path, "fragment:", u.Fragment)
fmt.Println("user:", u.User.Username(), "redacted:", u.Redacted())
q := u.Query()
fmt.Println("q:", q["q"], "first:", q.Get("q"), "limit:", q.Get("limit"))
// Build a URL safely: never concatenate user input.
v := url.Values{}
v.Set("q", "Dune & Messiah?")
v.Add("tag", "sci-fi")
next := url.URL{Scheme: "https", Host: "shelf.example", Path: "/books/search", RawQuery: v.Encode()}
fmt.Println(next.String())
fmt.Println(url.PathEscape("a b/c"), url.QueryEscape("a b/c"))
fmt.Println(u.JoinPath("..", "loans").Path)
rel, _ := url.Parse("../authors?id=7")
fmt.Println(u.ResolveReference(rel))
// A scheme-less string is not a URL with a host.
bad, _ := url.Parse("shelf.example:8443/books")
fmt.Printf("scheme=%q opaque=%q\n", bad.Scheme, bad.Opaque)
_, err = url.Parse("http://[::1")
fmt.Println("error:", err)
// net/netip: small, comparable, allocation-free addresses.
a := netip.MustParseAddr("192.0.2.17")
p := netip.MustParsePrefix("192.0.2.0/24")
fmt.Println(a, a.Is4(), p.Contains(a), p.Masked(), a.IsPrivate(), netip.MustParseAddr("10.1.2.3").IsPrivate())
ap, _ := netip.ParseAddrPort("[2001:db8::1]:8080")
fmt.Println(ap.Addr(), ap.Port(), ap.Addr().Is6())
_, err = netip.ParseAddr("999.0.0.1")
fmt.Println("error:", err)
fmt.Println(a == netip.AddrFrom4([4]byte{192, 0, 2, 17}))
host, port, _ := net.SplitHostPort("[2001:db8::1]:8080")
fmt.Println(host, port, net.JoinHostPort("2001:db8::1", "8080"))
}
scheme: https host: shelf.example:8443 hostname: shelf.example port: 8443
path: /books/search fragment: top
user: reader redacted: https://reader:xxxxx@shelf.example:8443/books/search?q=dune&q=herbert&limit=10#top
q: [dune herbert] first: dune limit: 10
https://shelf.example/books/search?q=Dune+%26+Messiah%3F&tag=sci-fi
a%20b%2Fc a+b%2Fc
/books/loans
https://reader:example-only@shelf.example:8443/authors?id=7
scheme="shelf.example" opaque="8443/books"
error: parse "http://[::1": missing ']' in host
192.0.2.17 true true 192.0.2.0/24 false true
2001:db8::1 8080 true
error: ParseAddr("999.0.0.1"): IPv4 field has value >255
true
2001:db8::1 8080 [2001:db8::1]:8080
-
url.Parseaccepts almost anything:shelf.example:8443/booksparses without error as a URL whose scheme isshelf.exampleand whose opaque part is8443/books, which is not what you meant. Checku.Schemeandu.Hostafter parsing a URL from input;url.ParseRequestURIis stricter and is meant for server-side request lines. Go 1.26 madeParsereject malformed hosts with extra colons such ashttp://localhost:80:80/(theurlstrictcolonsGODEBUG setting restores the old behaviour); the upcoming Go 1.28 addsurl.MustParsefor constants. -
Build URLs with
url.URLandurl.Values, never by concatenating strings:Values.Encode()escapes and sorts keys,PathEscapeandQueryEscapediffer in how they treat/and spaces,JoinPathandResolveReferencedo the dot-segment arithmetic of RFC 3986, andu.Redacted()replaces a password withxxxxxso URLs are safe to log. -
netip.Addr,AddrPortandPrefixare small value types: comparable with==, usable as map keys, allocation free and immutable, unlikenet.IP(a slice) andnet.IPNet. Prefer them in new code.Prefix.Contains,Masked,Addr.IsPrivate,Is4andIs6cover the usual access-control checks, and the parse functions return errors with precise messages.net.SplitHostPortandnet.JoinHostPorthandle the brackets IPv6 needs.
Alternatives and frameworks
With method and wildcard patterns, a router, middleware by composition, JSON, structured logging and a test server in the
standard library, a service can be written without any framework, and most Go services in production do exactly that.
Third-party routers and frameworks add things the standard library deliberately leaves out: route groups with
per-group middleware, binding and validation of request structs, regular-expression or typed routes, automatic OpenAPI
generation, and a larger catalogue of ready-made middleware. The official tutorial
Developing a RESTful API with Go and Gin shows what the framework style
looks like, and it is a reasonable first read after this page for readers who want to compare. A systematic comparison of
Go web frameworks (chi, Gin, Echo, Fiber and others) is outside this reference and is tracked in issue #263; this page
stays with the standard library. For gRPC, GraphQL, WebSocket and OpenAPI the same rule applies: they are separate
modules, and they all plug into net/http servers or listeners.
Where older Go books are outdated
The two books used as background for this section predate most of the features above. The table lists what changed; the current approach is what the page teaches.
| Older teaching or older code | Current approach | Since |
|---|---|---|
"`ServeMux` cannot route by method or path variable, so use a third-party router" (Bodner ch. 11); prefix-only routing (Vivien ch. 11) |
Patterns such as |
Go 1.22 |
|
|
Go 1.7 ( |
A server started with |
Your own mux and an |
|
A handler that writes the status after the body, sets no |
Headers, then |
|
|
|
Go 1.16 ( |
|
|
|
Before Go 1.25: token libraries for CSRF |
|
Go 1.25 |
Trailing-slash redirects with |
|
Go 1.26 |
Testing a server with a real port and sleeps |
|
|
|
|
Go 1.18 |
Beyond the technical changes, two outdated practices from the older HTTP chapters are not repeated here: a handler that
calls WriteHeader after writing the body, and a server started with http.ListenAndServe and no timeouts. The page
shows the failure of the first with the real server log message, and replaces the second with the configuration above.
Common mistakes
| Mistake | Fix |
|---|---|
Using |
A shared |
Creating a new |
One client per backend, reused; set |
|
Check |
Treating a 404 or 500 as success because |
Check |
Reading an unbounded response or request body |
|
|
An explicit |
Calling |
Headers, then status, then body; the server logs |
Registering routes on |
A private |
Two patterns that overlap without one being more specific |
It panics at start-up: add the method or a more specific literal to one of them. |
Using a |
Validate it; use |
A state-changing |
|
Wrapping |
Add |
Exiting on |
|
Passing the signal context as |
It cancels every in-flight request at once; give the server a context you cancel after the drain. |
|
|
Storing the request or its context after the handler returns |
Copy what you need; the context is cancelled when |
Version notes
-
Go 1.7:
Request.Context,Transport.DialContext;Transport.Dialdeprecated. -
Go 1.8:
Server.Shutdown. -
Go 1.13:
Server.BaseContextandConnContext. -
Go 1.16:
io/ioutildeprecated;io.ReadAllandio.NopCloserreplace its functions. -
Go 1.18:
net/netip. -
Go 1.20:
http.ResponseController;httputil.ReverseProxy.Rewrite. -
Go 1.21:
ResponseController.EnableFullDuplex. -
Go 1.22: ServeMux method and wildcard patterns,
Request.PathValueandSetPathValue;ServeFileFS,FileServerFSandNewFileTransportFS; per-iteration loop variables make handler closures in loops safe; an emptyContent-Lengthheader is rejected unlessGODEBUG=httplaxcontentlength=1. -
Go 1.23:
Request.Pattern; spaces or tabs allowed after the method in patterns;ParseCookie,ParseSetCookie,Cookie.PartitionedandCookie.Quoted,Request.CookiesNamed;ServeContentand friends drop caching and encoding headers on errors;httptest.NewRequestWithContext. -
Go 1.24:
Server.Protocols,Transport.Protocols,HTTP2configuration, unencrypted HTTP/2;os.Root;b.Loopandt.Contextfor tests. -
Go 1.25:
http.CrossOriginProtection;testing/synctestgenerally available. -
Go 1.26: ServeMux redirects use status 307;
Transport.NewClientConn;HTTP2Config.StrictMaxConcurrentRequests; the client honoursRequest.Hostfor cookies;ReverseProxy.Directordeprecated;httptest.Server.Clientredirectsexample.com;net/url.Parserejects extra colons in hosts. -
Go 1.27:
httptest.NewTestServer(in-memory network, synctest-ready); HTTP/1Response.Body.Closedrains unread data up to a limit;Server.MaxHeaderValueCount;Server.DisableClientPriorityand RFC 9218 priority in the HTTP/2 server; ALPN negotiation on custom connections. -
Go 1.28 (upcoming, not released at the time of writing): the draft notes list
url.MustParseand an HTTPQUERYmethod, among others; treat them as upcoming and re-check when it ships.