67. Routing with `ServeMux`

📖 Reading · 16 min
💡 Every code box below is live — edit it and hit Run.

One handler is a program. A service has many, and something has to decide which one gets each request. That something is a router, and Go ships one: http.ServeMux.

For years Go's mux was too limited for real APIs — no method matching, no path parameters — and everyone reached for a third-party router. Go 1.22 fixed that. For most services you no longer need a dependency at all.

Registering routes

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "ok")
    })
    mux.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "hi %s", r.URL.Query().Get("name"))
    })

    for _, path := range []string{"/health", "/greet?name=ada", "/nope"} {
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
        fmt.Printf("%-18s %d %q\n", path, rec.Code, rec.Body.String())
    }
}
/health            200 "ok"
/greet?name=ada    200 "hi ada"
/nope              404 "404 page not found\n"

A ServeMux is itself a Handler — it has a ServeHTTP method, which is why we can drive it exactly like a single handler in the last lesson. That's not a coincidence; it's the whole design. A router that is a handler can be nested inside another router, wrapped in middleware, or handed to a server, without any of them knowing the difference.

Two methods register routes, and they differ only in what they take:

  • HandleFunc(pattern, func) — takes a function, wraps it in http.HandlerFunc for you.
  • Handle(pattern, handler) — takes anything implementing http.Handler.

Unmatched paths get a 404 with a plain-text body, for free.

Method and path parameters

This is the Go 1.22 part, and it's most of why you don't need a router library:

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "read item %s", r.PathValue("id"))
    })
    mux.HandleFunc("DELETE /items/{id}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "deleted item %s", r.PathValue("id"))
    })

    type call struct{ method, path string }
    for _, c := range []call{
        {"GET", "/items/42"},
        {"DELETE", "/items/42"},
        {"POST", "/items/42"},
    } {
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
        fmt.Printf("%-7s %-12s %d %q allow=%q\n",
            c.method, c.path, rec.Code, rec.Body.String(), rec.Result().Header.Get("Allow"))
    }
}
GET     /items/42    200 "read item 42" allow=""
DELETE  /items/42    200 "deleted item 42" allow=""
POST    /items/42    405 "Method Not Allowed\n" allow="DELETE, GET, HEAD"

A pattern is now [METHOD ]/path, and a {name} segment captures a value you read back with r.PathValue("name").

Look closely at the last line, because you got two things without writing them:

  • The POST returned 405 Method Not Allowed, not 404. The mux knows the path exists and that no POST handler is registered for it.
  • It set an Allow header listing the methods that are registered. That header is required by the HTTP spec for a 405, and forgetting it is a classic hand-rolled-router bug.

HEAD is in that list even though nothing registered it. Registering GET registers HEAD too — a HEAD request runs your GET handler and discards the body, which is exactly the behaviour the spec asks for.

The three wildcard forms

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "the root, and only the root")
    })
    mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "one item: %s", r.PathValue("id"))
    })
    mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "everything after /files/: %s", r.PathValue("path"))
    })

    for _, p := range []string{"/", "/items/9", "/files/docs/2026/report.pdf", "/nope"} {
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
        fmt.Printf("%-30s %d %s\n", p, rec.Code, rec.Body.String())
    }
}
/                              200 the root, and only the root
/items/9                       200 one item: 9
/files/docs/2026/report.pdf    200 everything after /files/: docs/2026/report.pdf
/nope                          404 404 page not found
form matches notes
{id} exactly one path segment the common case
{path...} all remaining segments, slashes included must be last
{$} the end of the path, nothing more anchors an exact match

{$} exists because of a rule that surprises everyone, covered next.

Trailing slashes and subtrees

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /items/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "subtree handler")
    })

    for _, p := range []string{"/items/", "/items", "/items/a/b"} {
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
        fmt.Printf("%-12s %d %q location=%q\n",
            p, rec.Code, rec.Body.String(), rec.Result().Header.Get("Location"))
    }
}
/items/      200 "subtree handler" location=""
/items       307 "<a href=\"/items/\">Temporary Redirect</a>.\n\n" location="/items/"
/items/a/b   200 "subtree handler" location=""

A pattern ending in / matches the whole subtree below it. So /items/ catches /items/a/b as well, and the mux redirects the bare /items to /items/ with a 307 Temporary Redirect.

That status is worth a moment, because it used to be 301 Moved Permanently and the change fixed a real bug. A client receiving a 301 is allowed to turn a redirected POST into a GET and drop the body — so a form submission to the wrong slash could silently arrive as an empty GET. 307 preserves the method and the body, which is why Go switched to it. If you see 301 here, you're on an older Go.

Still, don't rely on the redirect for an API. If you want /items and nothing beneath it, write /items without the slash, or /items/{$} when the trailing-slash form should be the only match.

Precedence: the most specific pattern wins

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "generic handler, id=%s", r.PathValue("id"))
    })
    mux.HandleFunc("GET /items/new", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "the specific /items/new handler")
    })

    for _, p := range []string{"/items/new", "/items/123"} {
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
        fmt.Printf("%-14s %s\n", p, rec.Body.String())
    }
}
/items/new     the specific /items/new handler
/items/123     generic handler, id=123

Registration order does not matter. The literal /items/new wins over the wildcard /items/{id} even though it was registered second, because it is more specific. If you've used routers where the first match wins, unlearn that here.

Two patterns that are equally specific and overlap — neither more precise than the other — are a conflict, and the mux panics when you register the second one. That sounds harsh, but it happens at startup rather than in production, and the panic message names both patterns.

Mounting a sub-router

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    api := http.NewServeMux()
    api.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "items list (handler saw path %q)", r.URL.Path)
    })

    root := http.NewServeMux()
    root.Handle("/api/v1/", http.StripPrefix("/api/v1", api))

    rec := httptest.NewRecorder()
    root.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/items", nil))
    fmt.Println(rec.Code, rec.Body.String())
}
200 items list (handler saw path "/items")

Because a mux is a handler, you can register one inside another. StripPrefix removes the mounted prefix before the inner mux looks at the path — note the inner handler saw /items, not /api/v1/items, so it doesn't need to know where it was mounted. That's how you version an API without repeating /api/v1 in forty patterns.

Don't use http.HandleFunc

There is a package-level http.HandleFunc that registers on a global called http.DefaultServeMux. Avoid it. Global state means any package you import can add routes to your server, tests can't get a clean router, and you can't run two servers with different routes in one process. Always make your own with http.NewServeMux().

Your turn

Build a mux with two routes: GET /users/{id} responding user <id>, and POST /users responding 201 with the body created.

The last two calls in main are there to show what you get for free — you shouldn't need to write anything to handle them.

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()

    // GET /users/{id} -> "user <id>"
    // POST /users     -> 201, "created"

    type call struct{ method, path string }
    for _, c := range []call{
        {"GET", "/users/7"},
        {"POST", "/users"},
        {"DELETE", "/users/7"},
        {"GET", "/teams/7"},
    } {
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
        fmt.Printf("%-7s %-12s %d %q\n", c.method, c.path, rec.Code, rec.Body.String())
    }
}
package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "user %s", r.PathValue("id"))
    })
    mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusCreated)
        fmt.Fprint(w, "created")
    })

    type call struct{ method, path string }
    for _, c := range []call{
        {"GET", "/users/7"},
        {"POST", "/users"},
        {"DELETE", "/users/7"},
        {"GET", "/teams/7"},
    } {
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
        fmt.Printf("%-7s %-12s %d %q\n", c.method, c.path, rec.Code, rec.Body.String())
    }
}

Next: responses that aren't plain text — JSON in, JSON out.