67. Routing with `ServeMux`
One handler is a program. A service has many, and something has to decide which
one gets each request. That something is a router, and Go ships one:
http.ServeMux.
For years Go's mux was too limited for real APIs — no method matching, no path parameters — and everyone reached for a third-party router. Go 1.22 fixed that. For most services you no longer need a dependency at all.
Registering routes
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
mux.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hi %s", r.URL.Query().Get("name"))
})
for _, path := range []string{"/health", "/greet?name=ada", "/nope"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
fmt.Printf("%-18s %d %q\n", path, rec.Code, rec.Body.String())
}
}
/health 200 "ok"
/greet?name=ada 200 "hi ada"
/nope 404 "404 page not found\n"
A ServeMux is itself a Handler — it has a ServeHTTP method, which is
why we can drive it exactly like a single handler in the last lesson. That's
not a coincidence; it's the whole design. A router that is a handler can be
nested inside another router, wrapped in middleware, or handed to a server,
without any of them knowing the difference.
Two methods register routes, and they differ only in what they take:
HandleFunc(pattern, func)— takes a function, wraps it inhttp.HandlerFuncfor you.Handle(pattern, handler)— takes anything implementinghttp.Handler.
Unmatched paths get a 404 with a plain-text body, for free.
Method and path parameters
This is the Go 1.22 part, and it's most of why you don't need a router library:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "read item %s", r.PathValue("id"))
})
mux.HandleFunc("DELETE /items/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "deleted item %s", r.PathValue("id"))
})
type call struct{ method, path string }
for _, c := range []call{
{"GET", "/items/42"},
{"DELETE", "/items/42"},
{"POST", "/items/42"},
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
fmt.Printf("%-7s %-12s %d %q allow=%q\n",
c.method, c.path, rec.Code, rec.Body.String(), rec.Result().Header.Get("Allow"))
}
}
GET /items/42 200 "read item 42" allow=""
DELETE /items/42 200 "deleted item 42" allow=""
POST /items/42 405 "Method Not Allowed\n" allow="DELETE, GET, HEAD"
A pattern is now [METHOD ]/path, and a {name} segment captures a value you
read back with r.PathValue("name").
Look closely at the last line, because you got two things without writing them:
- The
POSTreturned 405 Method Not Allowed, not 404. The mux knows the path exists and that noPOSThandler is registered for it. - It set an
Allowheader listing the methods that are registered. That header is required by the HTTP spec for a 405, and forgetting it is a classic hand-rolled-router bug.
HEAD is in that list even though nothing registered it. Registering GET
registers HEAD too — a HEAD request runs your GET handler and discards
the body, which is exactly the behaviour the spec asks for.
The three wildcard forms
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "the root, and only the root")
})
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "one item: %s", r.PathValue("id"))
})
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "everything after /files/: %s", r.PathValue("path"))
})
for _, p := range []string{"/", "/items/9", "/files/docs/2026/report.pdf", "/nope"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
fmt.Printf("%-30s %d %s\n", p, rec.Code, rec.Body.String())
}
}
/ 200 the root, and only the root
/items/9 200 one item: 9
/files/docs/2026/report.pdf 200 everything after /files/: docs/2026/report.pdf
/nope 404 404 page not found
| form | matches | notes |
|---|---|---|
{id} |
exactly one path segment | the common case |
{path...} |
all remaining segments, slashes included | must be last |
{$} |
the end of the path, nothing more | anchors an exact match |
{$} exists because of a rule that surprises everyone, covered next.
Trailing slashes and subtrees
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /items/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "subtree handler")
})
for _, p := range []string{"/items/", "/items", "/items/a/b"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
fmt.Printf("%-12s %d %q location=%q\n",
p, rec.Code, rec.Body.String(), rec.Result().Header.Get("Location"))
}
}
/items/ 200 "subtree handler" location=""
/items 307 "<a href=\"/items/\">Temporary Redirect</a>.\n\n" location="/items/"
/items/a/b 200 "subtree handler" location=""
A pattern ending in / matches the whole subtree below it. So /items/
catches /items/a/b as well, and the mux redirects the bare /items to
/items/ with a 307 Temporary Redirect.
That status is worth a moment, because it used to be 301 Moved Permanently
and the change fixed a real bug. A client receiving a 301 is allowed to turn
a redirected POST into a GET and drop the body — so a form submission to
the wrong slash could silently arrive as an empty GET. 307 preserves the
method and the body, which is why Go switched to it. If you see 301 here,
you're on an older Go.
Still, don't rely on the redirect for an API. If you want /items and nothing
beneath it, write /items without the slash, or /items/{$} when the
trailing-slash form should be the only match.
Precedence: the most specific pattern wins
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "generic handler, id=%s", r.PathValue("id"))
})
mux.HandleFunc("GET /items/new", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "the specific /items/new handler")
})
for _, p := range []string{"/items/new", "/items/123"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
fmt.Printf("%-14s %s\n", p, rec.Body.String())
}
}
/items/new the specific /items/new handler
/items/123 generic handler, id=123
Registration order does not matter. The literal /items/new wins over the
wildcard /items/{id} even though it was registered second, because it is more
specific. If you've used routers where the first match wins, unlearn that here.
Two patterns that are equally specific and overlap — neither more precise than the other — are a conflict, and the mux panics when you register the second one. That sounds harsh, but it happens at startup rather than in production, and the panic message names both patterns.
Mounting a sub-router
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
api := http.NewServeMux()
api.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "items list (handler saw path %q)", r.URL.Path)
})
root := http.NewServeMux()
root.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
rec := httptest.NewRecorder()
root.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/items", nil))
fmt.Println(rec.Code, rec.Body.String())
}
200 items list (handler saw path "/items")
Because a mux is a handler, you can register one inside another. StripPrefix
removes the mounted prefix before the inner mux looks at the path — note the
inner handler saw /items, not /api/v1/items, so it doesn't need to know
where it was mounted. That's how you version an API without repeating
/api/v1 in forty patterns.
Don't use http.HandleFunc
There is a package-level http.HandleFunc that registers on a global called
http.DefaultServeMux. Avoid it. Global state means any package you import can
add routes to your server, tests can't get a clean router, and you can't run two
servers with different routes in one process. Always make your own with
http.NewServeMux().
Your turn
Build a mux with two routes: GET /users/{id} responding user <id>, and
POST /users responding 201 with the body created.
The last two calls in main are there to show what you get for free — you
shouldn't need to write anything to handle them.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
// GET /users/{id} -> "user <id>"
// POST /users -> 201, "created"
type call struct{ method, path string }
for _, c := range []call{
{"GET", "/users/7"},
{"POST", "/users"},
{"DELETE", "/users/7"},
{"GET", "/teams/7"},
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
fmt.Printf("%-7s %-12s %d %q\n", c.method, c.path, rec.Code, rec.Body.String())
}
}
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "user %s", r.PathValue("id"))
})
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, "created")
})
type call struct{ method, path string }
for _, c := range []call{
{"GET", "/users/7"},
{"POST", "/users"},
{"DELETE", "/users/7"},
{"GET", "/teams/7"},
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
fmt.Printf("%-7s %-12s %d %q\n", c.method, c.path, rec.Code, rec.Body.String())
}
}
Next: responses that aren't plain text — JSON in, JSON out.