Routing: The Router Grew Up in 1.22
The modern http.ServeMux with method and wildcard patterns, r.PathValue, the automatic 405 with an Allow header, precedence rules, the {$} end-anchor, and the {rest...} trailing wildcard. Why this removed most reasons to reach for a third-party router. Compiled and run against Go 1.26.5.
For most of Go’s life, its built-in router was the standard library’s weakest link. It matched paths and nothing else: no methods, no path parameters, no GET /books/{id}. So every real project reached for a third-party router (gorilla/mux, chi, httprouter) on day one, and “which router” became a question you had to answer before writing a line of business logic. Go 1.22 changed that. It taught the built-in http.ServeMux to match HTTP methods and capture path segments, and in doing so removed the reason most services ever imported a router at all. This chapter is that feature, run against Go 1.26.5.
Patterns with a method and a wildcard
A modern route pattern has two new powers: an optional method out front, and named wildcards inside the path. Here is what a small book service looks like:
mux := http.NewServeMux()
mux.HandleFunc("GET /books/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "one book: %s", r.PathValue("id"))
})
mux.HandleFunc("POST /books/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "updated book: %s", r.PathValue("id"))
})
Two things are new. The pattern string starts with GET or POST , so the method is part of the match, not something you check inside the handler with an if r.Method != .... And {id} is a wildcard segment: it matches any single path segment and captures it under the name id. You read the captured value back with r.PathValue("id"). The same path, /books/{id}, is registered twice with different methods and different handlers, which is exactly how a REST resource wants to be shaped. Requests land where you’d hope:
GET /books/42 -> 200 "one book: 42"
POST /books/42 -> 200 "updated book: 42"
PathValue returns a string; if the segment wasn’t in the matched pattern you get "". There is no typed conversion and no validation, so an {id} you expect to be a number is still your job to strconv.Atoi and reject if it isn’t.
The automatic 405
Here is the feature that most justifies deleting your router dependency. Because the mux now knows which methods a path supports, it can answer a request that hits a known path with an unknown method correctly, on its own. Send a DELETE to a path that only registered GET and POST:
DELETE /books/42 -> 405 "Method Not Allowed" Allow=GET, HEAD, POST
A 405 Method Not Allowed, with a correct Allow header listing the methods that path does accept, generated by the router with no code from you. Note the Allow header lists GET, HEAD, POST — you registered GET and POST, and the mux added HEAD for free, because a handler that answers GET can answer HEAD (a bodyless GET) automatically. Getting method-not-allowed semantics right by hand is fiddly and easy to botch; here it is simply the default behavior.
Precedence: the most specific pattern wins
When two patterns can both match a request, the mux does not pick the first one registered or the last. It picks the most specific one, and it does so regardless of registration order. Add a literal route alongside the wildcard:
mux.HandleFunc("GET /books/featured", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "the featured shelf")
})
Now /books/featured could match either GET /books/{id} (with id = "featured") or the literal GET /books/featured. The literal is more specific, so it wins:
GET /books/featured -> 200 "the featured shelf"
You could register these two in either order and get the same result. The rule is that a pattern that matches a strict subset of another’s requests takes precedence. If two patterns overlap but neither is more specific than the other, that is genuinely ambiguous, and the mux panics at registration time rather than guessing. You find out at startup, not in production.
Anchoring the root with {$}
The old mux had an infamous quirk: a pattern ending in / matched that path and everything under it as a prefix. So / matched literally every request, making it useless as a “home page only” route. The fix is a new anchor, {$}, which means “the path ends exactly here”:
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "home")
})
GET /{$} matches only /, and nothing deeper:
GET / -> 200 "home"
GET /anything/else -> 404 "404 page not found"
Without the {$}, that GET / would have swallowed /anything/else too. With it, / is a leaf, and unmatched paths fall through to the router’s 404. Any pattern that should match a path exactly and not as a prefix wants the {$} anchor at its end.
The trailing wildcard {rest…}
Sometimes you do want to match a whole subtree — serving static files, proxying a path, anything where everything after a prefix is one opaque value. The {name...} form captures all remaining segments, slashes included:
mux.HandleFunc("GET /static/{rest...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "serving file: %s", r.PathValue("rest"))
})
Everything after /static/ lands in rest, embedded slashes and all:
GET /static/css/site.css -> 200 "serving file: css/site.css"
A plain {rest} would only match one segment and reject the slash; the ... makes it greedy to the end of the path. That is the one wildcard that can contain /, and it must be the last thing in the pattern.
Subtree patterns and the redirect you didn’t write
There’s an older matching form still worth knowing, because it predates wildcards and the mux still honors it. A pattern ending in a plain slash — GET /files/ — is a subtree pattern: it matches that path and everything beneath it as a prefix. It behaves much like /files/{rest...}, minus the capture. What surprises people is the free redirect that comes with it. Request the subtree without the trailing slash and the mux bounces you:
/files/ -> 200 Location="" body="files subtree"
/files -> 307 Location="/files/" body="Temporary Redirect"
/files/a/b -> 200 Location="" body="files subtree"
GET /files (no trailing slash) got a 307 Temporary Redirect to /files/, not a direct 200. The mux canonicalizes the path for you. Note the code is 307, not the 301 older Go used and older tutorials still claim; 307 preserves the method and body across the redirect, which matters for a POST. It’s a small thing, but it’s the kind of stale detail that a book written from memory gets wrong, so: subtree redirect, and it’s a 307.
Do you still need a third-party router?
For the overwhelming majority of services, no. Methods, path parameters, correct 405s, HEAD handling, and sane precedence were the exact reasons the ecosystem reached for chi and gorilla/mux, and all of them now live in the standard library with zero dependencies. What the built-in mux still does not give you is regex constraints on segments ({id:[0-9]+}), typed parameter extraction, or per-route middleware chains as first-class syntax. If you want those ergonomics, a router still earns its place. But “I need to route GET /books/{id}” is no longer a reason to add one, and starting a new service on the standard mux is now the sensible default rather than a deliberate act of minimalism.
Final thoughts
The 1.22 http.ServeMux is a real router: mux.HandleFunc("GET /books/{id}", h) matches on method and captures path segments, which you read with r.PathValue("id"). A known path with an unregistered method returns an automatic 405 and a correct Allow header, HEAD included for free. The most specific pattern wins no matter the registration order, and a true ambiguity panics at startup instead of guessing. Anchor exact matches with {$} so / stops swallowing the whole tree, and capture a subtree with {rest...}, the one wildcard that keeps its slashes. Between them, these features retired the reflexive “add a router first” step for most Go services. Next we open the request up and read what the client actually sent.
Next: reading the request — query params, forms, headers, and safely reading the body.
Comments