Step 09 of 24
Routing & middleware
Parse path and query parameters safely, organise routes into groups, and compose middleware for logging, recovery and request context.
Once a service has more than a handful of endpoints, two questions take over: how to turn URLs into validated inputs, and how to apply cross-cutting behaviour (logging, panic recovery, request IDs) to every request without copying it into every handler. This lesson answers both using only the standard library: parsing path and query parameters, grouping routes, writing and chaining middleware, and passing request-scoped values through context.
Path parameters: from strings to valid values
r.PathValue("id") always returns a string. Your handler must convert it and reject anything invalid before it reaches the database. Because this repeats in many handlers, give it a helper:
// pathID parses a positive integer wildcard such as {id}.
func pathID(r *http.Request, name string) (int64, error) {
id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
if err != nil || id < 1 {
return 0, fmt.Errorf("%s must be a positive integer", name)
}
return id, nil
}
func (a *app) getProduct(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
p, ok := a.products[id]
if !ok {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "%d: %s\n", p.ID, p.Name)
}Distinguish the two failures. /products/abc can never name a product, so it is a malformed request: 400. /products/999 is well-formed but absent: 404. Some APIs return 404 for both to keep things simple; either is defensible as long as you are consistent.
Values come back already percent-decoded, so /files/my%20notes.txt matched by GET /files/{name} gives "my notes.txt". If a wildcard ends up in a file path or a query, validate it as strictly as any other user input.
Query strings
Query parameters carry optional modifiers: filters, sorting, pagination. r.URL.Query() parses them into url.Values, which is a map[string][]string:
// GET /products?q=lamp&tag=desk&tag=office&debug
q := r.URL.Query() // parses on every call, so call it once
search := q.Get("q") // "lamp"; "" if absent
tags := q["tag"] // ["desk" "office"]: all values for a repeated key
debug := q.Has("debug")Everything is a string, and every parameter is optional, so you need defaults and bounds. A small helper keeps handlers readable:
func intQuery(q url.Values, key string, def, lo, hi int) (int, error) {
s := q.Get(key)
if s == "" {
return def, nil
}
n, err := strconv.Atoi(s)
if err != nil || n < lo || n > hi {
return 0, fmt.Errorf("%s must be an integer between %d and %d", key, lo, hi)
}
return n, nil
}
limit, err := intQuery(q, "limit", 20, 1, 100)Always cap values like limit: without a maximum, ?limit=10000000 asks your database for everything. When you build URLs, for example a "next page" link, let url.Values handle escaping: url.Values{"q": {"go & web"}}.Encode() produces q=go+%26+web.
Organising routes
Keep all route registrations in one place, a routes method on your application struct, so anyone can see the whole API surface at a glance:
func (a *app) routes() http.Handler {
api := http.NewServeMux()
api.HandleFunc("GET /products", a.listProducts)
api.HandleFunc("GET /products/{id}", a.getProduct)
root := http.NewServeMux()
root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
root.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
return root
}The api mux is a route group. Its patterns are written relative to the group; http.StripPrefix removes /api/v1 from the path before the inner mux sees it, so /api/v1/products/7 matches GET /products/{id}. Because the group is a single Handler, you can also wrap the whole group in middleware, for example root.Handle("/admin/", requireAdmin(http.StripPrefix("/admin", adminMux))). For small services, registering full paths on one mux is perfectly fine; reach for groups when a set of routes shares a prefix and behaviour.
Middleware: wrapping handlers
Middleware is a function that takes a handler and returns a new handler that does something before and/or after calling the original:
func serverHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Server", "levelcode-demo") // before
next.ServeHTTP(w, r)
// after: next has finished writing the response
})
}
handler := serverHeader(mux)That is the whole pattern: func(http.Handler) http.Handler. Because the mux is a Handler and so is the result, middleware nests like layers of an onion. With a(b(mux)), a request passes through a, then b, then the mux and your handler; on the way out it unwinds through b and then a. Middleware can also short-circuit by writing a response and not calling next, which is how authentication or rate-limiting layers reject requests.
When middleware needs configuration, such as a logger, write a function that returns the middleware:
func logRequests(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
"request_id", RequestID(r.Context()),
)
})
}
}http.ResponseWriter has no way to read back the status a handler wrote, so the logger passes the handler a wrapper that remembers it:
type statusRecorder struct {
// Embedded, so Header and Write are promoted unchanged.
http.ResponseWriter
status int
}
func (rec *statusRecorder) WriteHeader(code int) {
rec.status = code
rec.ResponseWriter.WriteHeader(code)
}
// Unwrap lets http.ResponseController reach the original writer
// (for Flush, SetWriteDeadline and so on).
func (rec *statusRecorder) Unwrap() http.ResponseWriter { return rec.ResponseWriter }The status starts as 200 because a handler that only calls Write never calls WriteHeader on the wrapper; the underlying writer sends 200 implicitly.
time=2026-10-11T09:12:01.512Z level=INFO msg=request method=GET path=/api/v1/products/7 status=200 duration=84.1µs request_id=MOUQR7NFPAFKGD2WNPSDFA2MJ6Put the logger outside an authentication middleware and you get the onion below. Step through both requests: the first passes every layer and unwinds back out; the second is rejected by the auth layer, which never calls next, yet the logger still records it because it wraps everything.
Recovering from panics
If a handler panics, net/http recovers it so the process survives, but the client just sees the connection drop and the stack trace goes to the server's plain error log. A recovery middleware turns the panic into a proper 500 and a structured log entry:
func recoverPanics(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
if v == nil {
return
}
if v == http.ErrAbortHandler { // deliberate abort: let net/http handle it
panic(v)
}
logger.Error("panic", "value", v, "stack", string(debug.Stack()))
w.Header().Set("Connection", "close")
http.Error(w, "internal server error", http.StatusInternalServerError)
}()
next.ServeHTTP(w, r)
})
}
}Chaining middleware
Nesting calls by hand, a(b(c(mux))), gets hard to read. A tiny helper applies a list so that the first middleware listed is the outermost:
type middleware = func(http.Handler) http.Handler
func chain(h http.Handler, mws ...middleware) http.Handler {
for _, mw := range slices.Backward(mws) { // wrap from the last to the first
h = mw(h)
}
return h
}
handler := chain(mux,
withRequestID, // 1st: runs first, so everything below sees the ID
logRequests(logger), // 2nd: logs the final status, including 500s
recoverPanics(logger), // 3rd: closest to the handlers
)Order is a design decision, not a detail. Here withRequestID must wrap the logger so the logger can read the ID, and recoverPanics must sit inside the logger so a recovered panic is logged with status 500. Swap the last two and a panic would bypass the logging code entirely.
Middleware can also be applied to a single route, by wrapping just that handler when you register it: api.Handle("POST /products", requireJSON(http.HandlerFunc(a.createProduct))).
Request context: request-scoped values
Every request carries a context.Context from r.Context(). You met it as a cancellation signal: the server cancels it when the client disconnects, so pass it to database and outbound HTTP calls. It can also carry request-scoped values from middleware to handlers:
type ctxKey int
const requestIDKey ctxKey = iota
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := rand.Text() // crypto/rand, Go 1.24+: 26 random base32 characters
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequestID returns the ID stored by withRequestID, or "" if there is none.
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}Three details make this robust. The key is an unexported type, so no other package can create an equal key and collide with yours. r.WithContext returns a copy of the request carrying the new context, which is passed on; the original request is untouched. And the typed getter hides the type assertion, so callers get a plain string.
Practice
Extend the product API:
- Write
requireJSONmiddleware that responds415 Unsupported Media Typeunless the request'sContent-Typeisapplication/json(parameters like; charset=utf-8are allowed). Usemime.ParseMediaType. - Write
listProductsforGET /products, supporting?limit=(default 20, range 1–100) and?sort=(nameorprice, defaultname). Invalid values get400. For now, just echo the parsed settings. - Register
POST /productswithrequireJSONapplied to that route only, mount the group under/api/v1/, and wrap everything withwithRequestID,logRequestsandrecoverPanics.
Show solution
This builds on pathID, intQuery, chain, statusRecorder, logRequests, recoverPanics, withRequestID and RequestID from the lesson.
func requireJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mt != "application/json" {
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listProducts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit, err := intQuery(q, "limit", 20, 1, 100)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sort := cmp.Or(q.Get("sort"), "name") // first non-empty value
if sort != "name" && sort != "price" {
http.Error(w, "sort must be name or price", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "limit=%d sort=%s\n", limit, sort)
}
func (a *app) createProduct(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) // decoding the JSON body comes next lesson
}
func (a *app) routes() http.Handler {
api := http.NewServeMux()
api.HandleFunc("GET /products", a.listProducts)
api.HandleFunc("GET /products/{id}", a.getProduct)
api.Handle("POST /products", requireJSON(http.HandlerFunc(a.createProduct)))
root := http.NewServeMux()
root.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
return chain(root, withRequestID, logRequests(a.logger), recoverPanics(a.logger))
}
type product struct {
ID int64
Name string
}
type app struct {
logger *slog.Logger
products map[int64]product
}
func main() {
a := &app{
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
products: map[int64]product{7: {ID: 7, Name: "Desk lamp"}},
}
srv := &http.Server{Addr: ":8080", Handler: a.routes(), ReadHeaderTimeout: 5 * time.Second}
if err := srv.ListenAndServe(); err != nil {
a.logger.Error("server stopped", "err", err)
os.Exit(1)
}
}$ curl 'localhost:8080/api/v1/products?limit=5&sort=price'
limit=5 sort=price
$ curl 'localhost:8080/api/v1/products?limit=500'
limit must be an integer between 1 and 100
$ curl -i -X POST -d '{}' localhost:8080/api/v1/products
HTTP/1.1 415 Unsupported Media Type
$ curl -i -X POST -H 'Content-Type: application/json' -d '{}' localhost:8080/api/v1/products
HTTP/1.1 201 Createdcurl -d sends application/x-www-form-urlencoded by default, which is why the first POST is rejected. cmp.Or returns its first non-zero argument, a compact way to apply a default.
Key takeaways
- Convert and validate every path and query parameter; use
400for malformed input and404for well-formed IDs that do not exist. - Give query parameters defaults and upper bounds, and let
url.Valueshandle encoding. - Middleware is
func(http.Handler) http.Handler; it can act before and afternext, or short-circuit by not calling it. - Order matters: the first middleware in the chain runs first and finishes last.
- Wrap
ResponseWriterto observe the status, and recover panics in middleware to return a clean500. - Store request-scoped values in the context under an unexported key type, with a typed getter.
Check yourself
Quiz
0/4 correct · 4 left
1.The server is started with handler := logRequests(recoverPanics(mux)). A handler panics. In what order does the code run?
2.Why use an unexported type such as type ctxKey int for context value keys instead of a plain string like "requestID"?
3.A route is registered as GET /products/{id} and a client requests /products/abc. What is the most accurate response?
4.Why does a logging middleware wrap http.ResponseWriter in a statusRecorder?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.