What's the difference between the stdlib's ServeMux and third-party routers?
Quick Answer
As of Go 1.22, the standard library's http.ServeMux supports method-based routing ("GET /users") and path parameters (/users/{id}, read via r.PathValue("id")), which closed most of the practical gap with third-party routers like chi, gin, or gorilla/mux. What the stdlib mux still lacks: built-in middleware chaining helpers (you compose middleware manually by wrapping handlers), route groups/sub-routers with shared prefixes and middleware, wildcard/regex pattern matching beyond simple path segments, and some convenience features like automatic OPTIONS handling. For a simple service, the standard library alone is often genuinely sufficient today; for a larger API with many routes needing shared middleware stacks and more expressive routing, a third-party router still commonly earns its dependency.
Detailed Answer
Go 1.22's ServeMux improvements genuinely changed the calculus for whether a project needs a third-party router at all.
What ServeMux can do natively since Go 1.22
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)
mux.HandleFunc("DELETE /users/{id}", deleteUserHandler)
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // extracts the {id} segment
// ...
}
Method-specific routes and path parameters were the two biggest reasons people historically reached for gorilla/mux or chi — both are now built in.
What third-party routers still add
// chi example: route groups and middleware chains
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/users/{id}", getUserHandler)
r.Post("/users", createUserHandler)
})
Grouping routes under a shared prefix with a shared middleware stack (r.Route("/api/v1", ...)) is still noticeably more convenient in a router like chi than composing it manually with the standard library's mux.
Composing middleware manually with the stdlib mux
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
})
}
handler := loggingMiddleware(mux)
http.ListenAndServe(":8080", handler)
This works fine for a single, global middleware chain, but grouping different middleware per route group takes more manual wiring than a router with built-in support for it.
The practical decision
For a small service with a handful of routes and simple middleware needs, the standard library alone is genuinely sufficient today. For a larger API with many route groups, each needing different middleware — auth on some routes, not others — a router like chi still reduces real boilerplate. It does this without pulling in a full web framework's opinions about everything else.