In Chapter 15, we registered four handlers on Go's global default mux using bare path strings. Now, we will replace that global mux with an explicit http.NewServeMux() and route the Task API's first endpoints using Go 1.22's method-plus-path patterns.
Go 1.22 added method routing directly to the standard library's ServeMux. You can now write "GET /tasks" and "POST /tasks" as separate patterns, and the mux dispatches them to different handlers. This routing upgrade is the reason we can build the Task API without pulling in a third-party router.

Here is what we are wiring up:
mux := http.NewServeMux() we own, instead of the hidden global.GET /ping, GET /tasks, POST /tasks, GET /tasks/{id}.r.PathValue("id").Check out the finish branch to follow along:
bashgit checkout 16-routing-with-servemux-finish
In Chapter 15, http.HandleFunc wrote routes into a package-level global called http.DefaultServeMux, and passing nil to http.ListenAndServe(":8080", nil) told the server to use it.
That design creates friction as a codebase grows. First, the pattern was just a path string. A route like "/greet" matched every HTTP method. Distinguishing a GET request from a POST request meant reading r.Method inside the handler and returning a 405 error yourself. Second, http.DefaultServeMux is shared across all packages. Any imported library can write routes into it, meaning two packages can silently overwrite each other. A global mux is also harder to pass to an httptest server during testing without causing side effects. We will look closer at testing in Chapter 24.
http.NewServeMux() creates a fresh mux you control:
go// main.gomux := http.NewServeMux()log.Fatal(http.ListenAndServe(":8080", mux))
Notice that the second argument changes from nil to mux. Everything else stays the same. You just call mux.HandleFunc instead of http.HandleFunc. This gives you a single routing value that is explicitly wired up and easy to pass around.
Before Go 1.22, the pattern "/tasks" matched any method. To route GET and POST to different handlers, every Go team either wrote a switch r.Method inside a single handler or reached for chi or gorilla/mux. Go 1.22 added method routing to the pattern string itself.
Here is the complete main.go for this chapter:
go// main.gopackage mainimport ("fmt""log""net/http")func ping(w http.ResponseWriter, r *http.Request) {fmt.Fprintln(w, "pong")}func listTasks(w http.ResponseWriter, r *http.Request) {w.Header().Set("Content-Type", "application/json")fmt.Fprintln(w, `[{"id":1,"title":"Write the routing chapter"}]`)}func createTask(w http.ResponseWriter, r *http.Request) {w.WriteHeader(http.StatusCreated)fmt.Fprintln(w, "task created")}func getTask(w http.ResponseWriter, r *http.Request) {id := r.PathValue("id")fmt.Fprintf(w, "task %s\n", id)}func main() {mux := http.NewServeMux()mux.HandleFunc("GET /ping", ping)mux.HandleFunc("GET /tasks", listTasks)mux.HandleFunc("POST /tasks", createTask)mux.HandleFunc("GET /tasks/{id}", getTask)log.Fatal(http.ListenAndServe(":8080", mux))}
"GET /tasks" and "POST /tasks" are two separate registrations for the same path. A GET request goes to listTasks, while a POST request goes to createTask. The mux handles the dispatching automatically, so the handlers never need to inspect r.Method.
The handlers here are just stubs. listTasks returns a hard-coded JSON array, createTask acknowledges the request with a 201 status, and getTask echoes a path segment. We do not have database storage yet. That arrives in Section 4. For now, we are only focusing on routing.
The pattern "GET /tasks/{id}" introduces a wildcard: {id} matches exactly one non-empty path segment. Inside the handler, r.PathValue("id") returns that segment as a string:
go// main.gofunc getTask(w http.ResponseWriter, r *http.Request) {id := r.PathValue("id")fmt.Fprintf(w, "task %s\n", id)}
A request to GET /tasks/42 delivers "42". GET /tasks/abc delivers "abc". Two cases that do not match this pattern are /tasks/ (an empty segment) and /tasks/42/notes (two segments past /tasks/). In Chapter 21, we will convert this string to an integer using strconv.Atoi and look up the task in a database. For now, echoing the ID back to the client confirms the routing works.
We registered "GET /tasks" and "POST /tasks" but nothing for DELETE /tasks. Start the server with go run . and try:
bashcurl -i -X DELETE http://localhost:8080/tasks
bashcurl -i http://localhost:8080/nope

The DELETE request gets back a 405 Method Not Allowed response with an Allow: GET, HEAD, POST header. We did not have to write any code for this. The mux derives the set of allowed methods from the patterns registered for that path and sends the 405 automatically. Notice that HEAD is in the list. Registering a GET handler also serves HEAD requests for free.
GET /nope returns 404 page not found. These two error codes mean different things, and API clients care about the distinction:
When two patterns could match the same request, the more specific one wins regardless of registration order. A fixed segment beats a wildcard: if you registered both "GET /tasks/done" and "GET /tasks/{id}", a request to /tasks/done goes to "GET /tasks/done".
You can also register a subtree pattern by ending a path with a slash. For example, "GET /tasks/" would match /tasks/ and everything under it. We do not register one here. For a REST API where each sub-path is its own named route, a wildcard or fixed segment is usually the right choice, rather than a subtree.
The standard library ServeMux covers everything the Task API needs. You would consider a third-party router for:
r.Use(...), for example){id:[0-9]+} to reject non-numeric ids at the router level)Both chi and gorilla/mux are mature options. For this course, the standard library is enough, and Go 1.22 is the reason why.
Chapter 17 covers JSON encoding and decoding with encoding/json. The listTasks handler returns a hard-coded string today, so next we will encode real Go structs into responses and decode request bodies properly.