What's the difference between the stdlib's ServeMux and third-party routers?

5 minintermediategonet-httproutingservemux

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.

Related Resources