26.2 http.ServeMux: Pattern Matching and Route Registration
Right, so you want to build a web server in Go. You’ve probably already found http.ServeMux. It’s the router that ships with the standard library, and it’s your first, and often your best, choice. It’s not the flashiest kid on the block, but it’s reliable, predictable, and doesn’t require a 50-page manual to understand. Think of it as the sturdy, well-worn toolbox in your garage, not the multi-function gizmo from a late-night infomercial that promises to julienne fries.
Its job is simple: you give it a URL pattern and a handler, and when a request comes in, it matches the request’s URL path against all registered patterns to figure out which handler should do the work. Let’s get our hands dirty.
The Basics: Registering Routes
You create a new ServeMux with http.NewServeMux(), but honestly, you’ll often just use the default one hidden in the http package via functions like http.HandleFunc. I’ll show you the explicit way because it’s better practice—it keeps your routes contained and avoids any accidental global state shenanigans from the default.
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, world! This is the root.")
})
mux.HandleFunc("/about", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "This is the about page. We're great.")
})
mux.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Reach out! (But not really, this is an example)")
})
http.ListenAndServe(":8080", mux)
}
Fire this up and visit http://localhost:8080/about. It works exactly as you’d expect. The magic is in mux.HandleFunc, which takes a pattern string and your function. Under the hood, it converts your function into a handler and registers it.
How Pattern Matching Actually Works (The Gotchas)
This is where ServeMux gets interesting, and where most people get tripped up. Its matching logic is brutally simple: it finds the longest registered pattern that matches the request path. Let that sink in. Longest. Pattern.
Now, look at our example. What happens if you go to /contact/us? You’ll get a 404. Fine. But what about /about/ (with a trailing slash)? You’ll get a 404. Wait, what? This is ServeMux’s first “quirky” design choice. A pattern like /about is registered as an exact path. It does not implicitly handle trailing slashes or subpaths.
To handle a trailing slash, you have to be explicit. A pattern ending in a slash, like /about/, is treated as a subtree. It will match /about/ and any path beneath it, like /about/team, /about/company/history, etc.
mux.HandleFunc("/about/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "You're at the about subtree: %s", r.URL.Path)
})
Now, /about/ works and /about/team will show “You’re at the about subtree: /about/team”. But /about (without the slash) will still 404. You’ve effectively created two different routes. This is a common footgun. The best practice is to decide on a convention (with or without a trailing slash) and redirect users to the canonical version. You often see this on the web; go to example.com/about and it redirects you to example.com/about/ or vice versa.
Here’s the hierarchy of patterns, from most specific to least:
/images/foo.png(fixed path)/images/(subtree)/(the root subtree, which catches everything)
Which brings us to the next point…
The Catch-All Root Pattern
You saw we registered /. In ServeMux, a pattern of / is the catch-all. It matches every single request that hasn’t been matched by a longer, more specific pattern. This is why the order of registration doesn’t matter—the Mux always picks the longest matching pattern, not the first one.
This is brilliant in its simplicity. You don’t have to worry about the order you add routes. But it also means you must be cautious: if you register / early on, you might think your new /admin route is broken, when in reality, the / pattern is happily handling the request for /admin because it’s longer than, well, nothing. Always register your specific routes first, even though the mux will sort them correctly by length.
Host-Specific Routing
This is a killer feature many people miss. You can prefix your patterns with a hostname. This is perfect for routing api.yourdomain.com and yourdomain.com to different sets of handlers, all within the same ServeMux.
mux.HandleFunc("api.example.com/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Welcome to the JSON API, you beautiful developer.")
})
mux.HandleFunc("example.com/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "<h1>Welcome to our website!</h1>")
})
The pattern matching logic applies here too. The longest host+path pattern wins. This keeps you from needing a separate reverse proxy or router just for this common use case. It’s elegant and powerful.
When ServeMux Isn’t Enough
For all its strengths, ServeMux is deliberately minimal. It matches on the path. That’s it. It doesn’t match based on HTTP method (GET, POST, etc.). A route you register for /users will be called for a GET, a POST, a DELETE, a request from a bored alien with a custom method—everything. You have to handle method dispatch inside your handler by checking r.Method. This is the Go way: explicit over implicit.
It also doesn’t support variables in the path out of the box (like /users/{id}). You can do it with a trailing-slash pattern and parsing the path yourself, but it’s clunky. If you need that, you’ll likely graduate to a third-party router like gorilla/mux or chi. But for many, many APIs and websites, http.ServeMux is more than capable. Its simplicity is its greatest asset. You understand exactly what it’s doing, with no magic. And in software, the least magic is often the best magic.