Go 1.22's net/http ServeMux: Method and Wildcard Routing Without Extra Dependencies
Go 1.22 adds method and wildcard routing to net/http's ServeMux, letting you drop chi or gorilla/mux for many services. This post shows the new patterns, PathValue extraction, precedence rules, and a zero‑dependency CRUD example.
22 Nov 2025, 13:55 UTC

The problem: routing bloat in small services
Many Go services start with the standard library's net/http and quickly pull in a third‑party router (chi, gorilla/mux, httprouter) just to get method‑based routes and path parameters. That adds a dependency, expands the supply‑chain surface, and forces you to keep two routing mental models in sync.
Thesis: Go 1.22 makes the standard mux sufficient for most HTTP APIs
Since Go 1.22 (February 2024) http.ServeMux understands HTTP methods and wildcard segments. You can register "GET /items/{id}" or "POST /items/{$}" directly on the default mux, retrieve captured values with r.PathValue("id"), and rely on deterministic precedence rules that surface conflicts at startup.
Method‑based and wildcard patterns
The new pattern syntax is a space‑separated method followed by a path. A bare path (no method) still matches every method, preserving pre‑1.22 behaviour. Wildcards use curly braces: {name} captures a single path segment, while {$} matches only the exact end of the path (useful for distinguishing /items from /items/).
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /static/{$}", serveIndex) // exact match only
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
Reading captured values with PathValue
Inside a handler, r.PathValue("id") returns the captured segment as a string. It returns an empty string when the name isn’t present — it does not unescape or validate. You still need to sanity‑check the value (e.g., ensure it’s a valid UUID or integer) before using it.
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
// validate id …
fmt.Fprintf(w, "user %s", id)
}
Precedence, conflicts, and startup panics
Patterns are resolved by specificity: a method‑qualified pattern beats a bare one; a longer static prefix beats a wildcard; two wildcards of equal length conflict and cause a panic at registration time. This fails fast — you discover ambiguous routes before the server starts.
GET /items/{id}vsGET /items/new→ panic (neither is more specific).GET /items/{id}vsPOST /items/{id}→ both allowed, method distinguishes them./static/(trunk) still matches the whole subtree, so static file serving composes naturally.
Worked example: a tiny CRUD service with zero external routers
The following main.go registers four routes — two method‑specific, one exact‑match, one trunk — and runs on :8080. It compiles and runs on any Go 1.22+ toolchain.
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "GET item %s", id)
})
mux.HandleFunc("POST /items", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "created")
})
mux.HandleFunc("GET /health/{$}", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("./assets"))))
http.ListenAndServe(":8080", mux)
}
Test with curl:
# 200 with captured id
curl -i http://localhost:8080/items/42
# 405 because only GET is registered for /items/{id}
curl -i -X POST http://localhost:8080/items/42
# 200 exact match
curl -i http://localhost:8080/health
# 404 (no trailing slash)
curl -i http://localhost:8080/health/
Trade‑offs and limitations
- No built‑in 405 nuance — the mux returns 405 when a path matches but the method doesn’t. If you need custom 405 bodies or Allow headers, you’ll still write a small wrapper.
- PathValue is raw — no unescaping or validation. Treat it like any user input.
- Dynamic route registration can trigger startup panics if two modules independently register overlapping wildcards. Coordinate route definitions or guard registration with a central router builder.
- Version‑dependent semantics — Go 1.22 changed path sanitisation (dots, repeated slashes). The
GODEBUG=httpmuxgo121=1flag restores 1.21 behaviour; pin thegodirective ingo.modand run integration tests on each supported version.
Actionable next step
Add a tiny test program (like the example above) to your repository, run it under go test -run=TestRouting with both the default mux and GODEBUG=httpmuxgo121=1. Verify that your existing endpoints still return the expected status codes. If they do, you can safely drop the third‑party router from go.mod and shrink your dependency graph.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.