- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You reach for a router the moment a new Go service needs more than
two endpoints. chi,
gorilla/mux, httprouter,
gin. The reason
was never mysterious: the old http.ServeMux could not match on
method, could not pull an ID out of a path, and treated a trailing
slash as a subtree wildcard whether you wanted that or not. So you
added a dependency on day one.
Go 1.22 changed the default. The routing enhancements accepted in
proposal 61410 taught the
standard ServeMux two things it never knew: HTTP methods and path
wildcards. That covers the routing most services actually do. The
parts that bite: method prefixes, {id} wildcards, PathValue,
precedence, and the one edge case ({$}) that trips people who
assume Go kept the old trailing-slash behavior.
Everything here targets Go 1.22 or newer. Check with go version
before you delete a dependency.
Method-prefixed patterns
A pattern is now a string with an optional method, an optional host,
and a path. The method sits at the front:
mux := http.NewServeMux()
mux.HandleFunc("GET /items", listItems)
mux.HandleFunc("POST /items", createItem)
mux.HandleFunc("GET /items/{id}", getItem)
mux.HandleFunc("DELETE /items/{id}", deleteItem)
http.ListenAndServe(":8080", mux)
A POST /items request routes to createItem. A GET to the same
path routes to listItems. That single change removes the most
common reason people wrote a switch on r.Method inside one handler.
Two behaviors worth knowing. Registering GET also matches HEAD
requests to the same path, because the net/http server handles HEAD
by running the GET handler and dropping the body. And if a path
matches but the method does not, the mux replies 405 Method Not and sets the
AllowedAllow header for you:
mux.HandleFunc("GET /items/{id}", getItem)
// a DELETE to /items/42 with no DELETE handler returns:
// 405 Method Not Allowed
// Allow: GET, HEAD
A pattern with no method matches every method. That is still useful
for a catch-all, but for real endpoints you want the method there.
Path wildcards and {id}
A path segment wrapped in braces is a named wildcard. It matches one
segment and binds the value to a name:
mux.HandleFunc("GET /users/{id}/posts/{postID}", getPost)
GET /users/7/posts/99 binds id to 7 and postID to 99. A
wildcard matches exactly one non-empty segment, so /users//posts/99
does not match, and neither does /users/7/posts (the second
wildcard has nothing to bind).
There is also a trailing multi-segment form. Suffix the name with
... and it matches the rest of the path, slashes included:
mux.HandleFunc("GET /files/{path...}", serveFile)
GET /files/docs/go/spec.txt binds path to docs/go/spec.txt.
This is how you serve a tree, proxy a downstream path, or accept an
arbitrary key. The ... wildcard has to be the final segment of the
pattern; you cannot put anything after it.
Reading values with PathValue
The matched wildcards come off the request, not out of a context
key you have to remember:
func getItem(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
item, err := store.Find(r.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, item)
}
PathValue returns a string. If the name was never in the pattern,
you get the empty string, not a panic, so a typo fails quiet rather
than loud. Convert and validate like any other untrusted input:
func getUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
// ... use id
}
There is also SetPathValue (Go 1.23+), which lets middleware or
tests inject a value, but for normal handlers you only read.
Precedence: the most specific pattern wins
The old mux picked the longest matching pattern. The new one has a
real rule: when two patterns match the same request, the more
specific one wins, where "more specific" means it matches a strict
subset of the requests the other one matches.
mux.HandleFunc("GET /items/{id}", getItem)
mux.HandleFunc("GET /items/new", newItemForm)
A request for GET /items/new matches both patterns. The literal
/items/new matches a subset of what /items/{id} matches, so it
wins. You do not order these by registration; the rule is about
specificity, not sequence. Put the literal first or last, the result
is the same.
When neither pattern is more specific than the other, they conflict,
and the mux panics at registration time rather than guessing:
mux.HandleFunc("GET /a/{x}/c", h1)
mux.HandleFunc("GET /a/b/{y}", h2)
// both match GET /a/b/c, neither is more specific
// panic: ... conflicts with ... GET /a/{x}/c
That panic is a feature. A routing ambiguity that used to be a silent
production bug now stops the process the moment you wire it up. You
find it on the first go run, not at 3 a.m.
The trailing-slash trap and {$}
Here is the edge case that surprises people migrating from older Go.
A pattern that ends in a slash matches every path under it as a
subtree:
mux.HandleFunc("GET /static/", serveStatic)
// matches /static/, /static/a, /static/a/b/c, everything under it
That subtree behavior is the old net/http rule, and it is still
here. The surprise is the root pattern. GET / does not mean "the
home page." It means "every GET that nothing else matched," because
/ is the subtree root of the whole path space:
mux.HandleFunc("GET /", home)
// this is your catch-all, NOT just the index page
If you want a pattern to match one exact path and nothing under it,
end it with {$}:
mux.HandleFunc("GET /{$}", home) // only "/", not a catch-all
mux.HandleFunc("GET /static/{$}", dir) // only "/static/", not the tree
{$} anchors the match to the end of the path. It is the piece most
people miss when they assume the new mux abandoned the subtree rule.
It did not. It gave you an opt-out.
Hosts, and a small gotcha
Patterns can carry a host, which routes by the request's Host
header before the path:
mux.HandleFunc("api.example.com/health", apiHealth)
mux.HandleFunc("/health", localHealth)
A pattern with a host is more specific than one without, so a request
to api.example.com hits the first handler and everyone else hits
the second. Handy for multi-tenant setups without a reverse proxy in
front.
One thing the new mux still does, just like the old one: it cleans
the request path, so /items/../admin gets normalized before matching.
That is usually what you want. If you are routing on a path that must
stay byte-for-byte (some proxy setups), be aware the mux redirects
. and .. segments before your handler sees them.
When you still reach for a router
The standard mux now covers method matching, path parameters, and
subtree serving. That is enough for a large share of services. It is
not everything a full router gives you. You still reach for chi or
similar when you want:
- Grouped middleware with clean nesting. The stdlib mux has no built-in middleware chaining or route groups. You wrap handlers by hand, which works but gets verbose past a few layers.
-
Regex or typed path constraints.
{id}matches any segment. If you need{id:[0-9]+}to reject non-numeric IDs at the routing layer, the stdlib will not do it; you validate inside the handler. -
Per-route method listing and richer 405/404 hooks beyond the
default
Allowheader behavior. - Trailing-slash redirect policies configurable per route.
For a service that is mostly REST endpoints with a handful of path
parameters and a middleware or two you can compose yourself, the
answer changed in Go 1.22. You can ship it on the standard library
and add a router the day you actually need one of the features above,
not before.
Here is the shape of a small handler set with middleware composed by
hand, no dependencies:
func withLogging(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
start := time.Now()
h.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path,
time.Since(start))
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", getItem)
mux.HandleFunc("POST /items", createItem)
srv := &http.Server{
Addr: ":8080",
Handler: withLogging(mux),
}
log.Fatal(srv.ListenAndServe())
}
That is a real service. No third-party import, method-aware routes,
path parameters, and a logging middleware you can read top to bottom.
The routing enhancements are one of those changes that quietly shrink
your dependency list. Try deleting the router from your next small
service and see how far the standard library carries you. For most,
it is further than you expect.
The stdlib mux is a good default, and knowing exactly how its
precedence and {$} anchoring work is the kind of detail The
Complete Guide to Go Programming digs into alongside the rest of
net/http and the runtime it sits on. When routing grows past a flat
handler list, Hexagonal Architecture in Go is about keeping HTTP at
the edge of your app so the mux stays a thin adapter and your domain
never learns what a ServeMux is.

Top comments (0)