net/http as the framework: Handler, the 1.22 ServeMux, and routing without dependencies
http.Handler is one method and HandlerFunc adapts plain functions. Since Go 1.22 ServeMux routes by method and {id} wildcards with most-specific-wins precedence and panics on conflicts at registration. For most services the stdlib mux is the framework.
The dependency audit flagged it on a Friday: the router library holding up a payments API had been archived on GitHub — no maintainers, no CVE response, 47 transitive packages. The team budgeted two sprints for a migration to another framework. Then someone actually diffed what the library did for them: path parameters, method matching, a NotFound hook. That was it. The whole surface had been in the standard library since Go 1.22. The migration took one afternoon — r.PathValue("id") instead of the framework’s params map, GET /users/{id} instead of chained builder calls — and deleted 47 dependencies from go.sum. The only real bug they hit was instructive: two old routes that had silently shadowed each other in the framework’s first-match-wins order now made ServeMux panic at startup with a pattern conflict. The panic was the stdlib telling them about a routing ambiguity that had been shipping for two years.
One interface, one method
Before you reach for a router library, understand what the stdlib already gives you — the answer changes every dependency decision you make afterward.
Everything in Go’s HTTP stack — routers, middleware, frameworks, your code — speaks one contract:
type Handler interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// HandlerFunc adapts a plain function to the interface —
// it's a named function type with a ServeHTTP method that calls itself.
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { f(w, r) }HandlerFunc is the trick worth pausing on: it is a function type that implements the interface, so any function with the right signature converts into a Handler with zero allocation — mux.Handle("/x", http.HandlerFunc(myFunc)), or the shorthand mux.HandleFunc("/x", myFunc). And ServeMux itself implements Handler, which means routers nest: a mux can be a handler inside another mux, a middleware wraps a mux the same way it wraps a single endpoint. This uniformity is why Go’s ecosystem composes so cleanly — there is no framework-specific handler signature to bridge, just one method. The server side mirrors it: http.Server takes any Handler as its root; http.ListenAndServe(addr, mux) spawns one goroutine per connection, so your handler code is concurrent by default and must be safe for it — a plain map shared across handlers without a mutex is a data race on the second simultaneous request.
The 1.22 ServeMux: methods, wildcards, precedence
Until Go 1.22 the stdlib mux matched only literal prefixes — no methods, no path params — and that gap is what third-party routers filled for a decade. The 1.22 patterns close it:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser) // {id} matches one segment
mux.HandleFunc("POST /users", createUser) // method-specific
mux.HandleFunc("GET /files/{path...}", serveFile) // {path...} matches the rest
mux.HandleFunc("GET /{$}", home) // {$} = exactly "/", no subtree
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // wildcard value, no context dance
// ...
}The rules that matter in production:
- Method patterns: a pattern with a method only matches that method — except
GET, which also matchesHEAD(the server discards the body). A method-less pattern matches all methods. A request that matches a path but no method gets405 Method Not Allowedautomatically. - Precedence is most-specific-wins, not first-registered-wins:
GET /users/newbeatsGET /users/{id}, and/users/{id}beats/users/. Registration order is irrelevant — a deliberate design choice so a route’s behavior never silently depends on init order. - Conflicts panic at registration: if two patterns overlap and neither is more specific (
GET /a/{x}/cvsGET /a/b/{y}— both match/a/b/c),Handlepanics at startup. Loud and early beats quiet and wrong. - A trailing-slash pattern like
/static/matches the whole subtree and triggers a 301 redirect from/staticto/static/.
A service registers GET /users/{id} first and GET /users/new second. A request comes in for GET /users/new. Which handler runs, and why?
▸Why this works
Why panic on conflict instead of just picking an order? Because first-match-wins makes route resolution depend on code layout: move a registration between files, or change init order, and an endpoint silently swaps handlers — the exact two-years-latent bug from the Hook. Most-specific-wins is order-independent: the same set of patterns always resolves identically, and the only ambiguous cases left are true ties, which fail fast at startup where a deploy pipeline catches them, not at 3 a.m. on a request.
When you actually need a framework
When you find yourself reaching for a router library, ask what you actually need that the stdlib mux does not provide — the list is shorter than you expect.
Be honest about what third-party web frameworks add on top of this: parameter binding and validation, content negotiation helpers, middleware ecosystems, route groups. None of that is routing — the 1.22 mux matches per-request in well under a microsecond, which disappears next to a single 200 µs database round trip. The costs of a framework are real and recurring: a non-standard handler signature that every middleware must adapt to, a dependency tree you audit forever, and an abstraction between you and ResponseWriter exactly when you need Flusher or Hijacker. The pragmatic line most Go teams draw: start with net/http plus a thin middleware chain; reach for a router library only when you need something the mux genuinely lacks (regex constraints, per-route middleware DSLs). What you should not do is hand-roll validation badly and call it framework-free — pull a focused library for the focused job instead of a framework for everything.
Why does http.HandlerFunc(myFunc) satisfy the http.Handler interface even though myFunc is just a plain function?
- 01Explain how HandlerFunc turns a plain function into an http.Handler, and why ServeMux nesting falls out of the same design.
- 02Describe the 1.22 ServeMux precedence and conflict rules, and the failure mode they were designed to kill.
Go’s HTTP stack is one interface deep: Handler’s single ServeHTTP method is the contract for endpoints, routers, and middleware alike, and HandlerFunc adapts any plain function to it for free because it is a function type carrying its own ServeHTTP. The server spawns a goroutine per connection, so handler code is concurrent from the first deploy and shared state needs synchronization from day one. Since Go 1.22 the stdlib ServeMux covers what third-party routers existed for: method-qualified patterns where GET also serves HEAD, single-segment {id} wildcards read back with r.PathValue, {path...} for trailing rest, {$} for exact root, and automatic 405 when the path matches but the method does not. Precedence is most-specific-wins and deliberately ignores registration order — GET /users/new beats GET /users/{id} wherever it is declared — and patterns that overlap with no winner panic at registration, converting silent route shadowing into a startup failure your pipeline catches. Pattern matching costs well under a microsecond, noise next to any real handler work. Frameworks still earn their place for binding, validation, and middleware ecosystems, but that is ergonomics, not routing — and the price is a custom handler signature, a permanent dependency audit, and indirection in front of ResponseWriter exactly where Flusher and Hijacker live. Now when you evaluate a new router dependency, check first: does the mux lack something concrete? Default to the stdlib; add focused libraries for focused jobs.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.