Go Practice #2 Routing — Go 1.22+ ServeMux
In #1 First HTTP Server you only handled /. A real server handles many paths and methods.
Before Go 1.22 (2024) — the standard ServeMux didn’t support method matching or path parameters, so an external router was almost mandatory. From 1.22 — the standard alone is usually enough.
Go 1.22+ ServeMux #
mux := http.NewServeMux()
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
http.ListenAndServe(":8080", mux)- Method + path —
"GET /users" - Path parameter — like
{id}
Previously, every handler had to branch on if r.Method == "GET" by hand. From 1.22, one line covers it.
Reading path parameters #
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// ...
}r.PathValue("id") returns that slot’s value as a string. To get an integer, call strconv.Atoi yourself.
Wildcards and ...
#
mux.HandleFunc("GET /files/{path...}", serveFile){name}— one segment{name...}— every segment to the end
// /files/a/b/c.txt → r.PathValue("path") == "a/b/c.txt"Host matching #
mux.HandleFunc("api.example.com/health", apiHealth)
mux.HandleFunc("admin.example.com/health", adminHealth)Same path, but different handlers depending on the Host header. Useful for multi-domain services.
Match priority #
When several patterns match:
- More specific patterns win (host specified > unspecified, exact segment > wildcard, etc.)
- Same priority — panic (conflicts detected at registration)
The ambiguity of the previous ServeMux has been replaced by clear, deterministic rules.
Common pattern — paths ending in /
#
mux.HandleFunc("GET /api/", apiHandler) // anything under /api/* (prefix)
mux.HandleFunc("GET /api/{$}", apiRoot) // exactly /api/
mux.HandleFunc("GET /api", redirectToSlash) // exactly /api/api/— every path under/api/...(prefix)/api/{$}— only/api/(exact)/api— only/api(no slash)
This clear separation is new in 1.22. Previously, prefix and exact matching were entangled and confusing.
Group/prefix handling #
api := http.NewServeMux()
api.HandleFunc("GET /users", listUsers)
api.HandleFunc("GET /posts", listPosts)
root := http.NewServeMux()
root.Handle("/api/", http.StripPrefix("/api", api))Build a separate mux and strip the prefix with StripPrefix. A natural fit for separating API v1 and v2.
Version prefixes like /api/v1
#
v1 := http.NewServeMux()
v1.HandleFunc("GET /users", listUsers)
v2 := http.NewServeMux()
v2.HandleFunc("GET /users", listUsersV2)
root := http.NewServeMux()
root.Handle("/api/v1/", http.StripPrefix("/api/v1", v1))
root.Handle("/api/v2/", http.StripPrefix("/api/v2", v2))Two muxes with similar handlers — registered per prefix.
Where external routers fit #
Even though the 1.22 standard mux covers most needs, external routers still have a place in some cases.
go-chi/chi
#
import "github.com/go-chi/chi/v5"
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/users", func(r chi.Router) {
r.Get("/", listUsers)
r.Post("/", createUser)
r.Route("/{id}", func(r chi.Router) {
r.Use(loadUser)
r.Get("/", getUser)
r.Delete("/", deleteUser)
})
})
http.ListenAndServe(":8080", r)Strengths:
- Apply middleware per group
- Nested routers —
Route/Mount - Compatible with standard net/http — chi’s router is also
http.Handler
In large APIs, the route tree is readable and per-group middleware feels natural. With the standard ServeMux alone, slotting middleware per route is a bit awkward.
gorilla/mux
#
The classic go-to router before Go 1.22. Chi is now more actively maintained and the standard mux has caught up, so gorilla/mux is less commonly the first choice.
Larger frameworks — gin, echo, fiber
#
Frameworks bundling router + middleware + response helpers.
r := gin.Default()
r.GET("/users/:id", func(c *gin.Context) {
c.JSON(200, gin.H{"id": c.Param("id")})
})Strengths:
- Rich response helpers (JSON, render)
- Fast benchmarks (especially fiber on fasthttp)
- Faster development
Tradeoffs:
- Diverges from standard interfaces — mixing with other libraries is a bit awkward
- Learning curve — per-framework conventions
- fasthttp-based ones aren’t compatible with net/http
Subjective: Aligned with Go’s design philosophy (small standard + explicit) — chi tends to fit best. gin/echo feel familiar to those used to other languages/frameworks.
Which to pick? #
| Situation | Recommendation |
|---|---|
| Small service, fewer than 30 routes | standard ServeMux |
| Many groups/nesting — frequent per-route middleware | chi |
| Fast development/prototype, prefer rich response helpers | gin / echo |
| Extreme performance (special cases) | fiber (fasthttp) |
The default is to start with the standard library and switch to chi when you hit its limits. fasthttp-based options aren’t net/http compatible, which narrows your library choices.
Testing routes #
net/http/httptest — runs locally with no external dependencies.
func TestGetUser(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
req := httptest.NewRequest("GET", "/users/42", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}Detailed in #6 Testing.
Common pitfalls #
1) Registration conflict → panic #
mux.HandleFunc("GET /a/{id}", h1)
mux.HandleFunc("GET /a/{name}", h2) // ✗ same priority conflict → panic{id} and {name} occupy the same slot. Differently named wildcards still form the same pattern. It’s not a compile error — it panics at startup.
2) /api vs /api/
#
mux.HandleFunc("GET /api", a) // only /api
mux.HandleFunc("GET /api/", b) // /api/, /api/anythingA request to /api may be auto-redirected to /api/. If the client doesn’t follow redirects, it won’t get the expected response.
3) When you don’t specify the method #
mux.HandleFunc("/users", h)Without a method prefix, every method matches. To accept only POST, write "POST /users" explicitly.
Method-not-allowed #
If a path has only GET registered and a POST arrives — the standard ServeMux automatically returns 405 Method Not Allowed (1.22+). Earlier versions returned 404. A small change — but the appropriate response for a REST API.
Wrap-up #
What we covered:
- Go 1.22+ ServeMux — method + path parameter + host matching
- Read parameters with
r.PathValue("id") {...}wildcards,{$}exact match- Group with per-prefix mux +
StripPrefix - chi — when per-group middleware is frequent
- gin / echo / fiber — when response helpers or performance are decisive
- Registration conflicts panic at startup
/apiand/api/are different patterns
In the next post (#3 JSON I/O) we cover — safe input/output with encoding/json, decoding error handling, and input validation patterns.