Go Practice #2 Routing — Go 1.22+ ServeMux

5 min read

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 #

new 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 #

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 ... #

wildcards
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 #

host-based routing
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:

  1. More specific patterns win (host specified > unspecified, exact segment > wildcard, etc.)
  2. 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 / #

prefix vs exact match
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 #

prefix-specific mux
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 #

chi example
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 routersRoute / 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.

gin example
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? #

SituationRecommendation
Small service, fewer than 30 routesstandard ServeMux
Many groups/nesting — frequent per-route middlewarechi
Fast development/prototype, prefer rich response helpersgin / 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.

route test
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 #

common pitfall
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/ #

two different patterns
mux.HandleFunc("GET /api", a)     // only /api
mux.HandleFunc("GET /api/", b)    // /api/, /api/anything

A 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
  • /api and /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.

X