Right, so you’ve graduated from the basic http.HandleFunc and http.Handle tutorials. You’ve built a few routes. And you’ve probably already run into the first major headache of the old ServeMux: its routing is… let’s be charitable and call it “simplistic.” It does prefix matching, which means a route registered at /api/ will happily try to handle a request for /api/things/i/do/not/have. That’s not just annoying; it’s a potential security and logic nightmare. You end up writing a bunch of boilerplate code inside your handler to parse out IDs and validate paths. It feels like you’re fighting the standard library.

Well, rejoice. The Go team finally heard our collective grumbling. Starting in Go 1.22, the net/http ServeMux got a massive upgrade, gaining method-specific and wildcard routing. It’s like they gave us a whole new router without having to import a third-party package. Let’s break it down.

The New Patterns: Method Matching and Wildcards

The magic is in the new pattern syntax. The old ServeMux only understood static paths and a trailing slash for prefix matching. The new one understands two new concepts:

  1. Method Matchers: POST /items/create
  2. Wildcards: /items/{id} or /files/{path...}

You can finally register a handler only for GET requests to one path and only for POST requests to that same path. No more checking r.Method inside your handler and manually returning a 405 Method Not Allowed. The router now handles that HTTP correctness for you.

And wildcards? They are a game-changer. You can capture dynamic segments of the URL path directly in the pattern. The value you capture is available through the Request object’s new PathValue method.

package main

import (
    "fmt"
    "net/http"
)

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

    // Method-specific routing
    mux.HandleFunc("GET /items", listItems)
    mux.HandleFunc("POST /items", createItem)

    // Wildcard routing
    mux.HandleFunc("GET /items/{id}", getItem)
    mux.HandleFunc("PUT /items/{id}", updateItem)
    mux.HandleFunc("DELETE /items/{id}", deleteItem)

    // A wildcard that matches a whole path segment
    mux.HandleFunc("GET /static/{path}", serveStaticFile)

    http.ListenAndServe(":8080", mux)
}

func getItem(w http.ResponseWriter, r *http.Request) {
    // This is the new, glorious way
    id := r.PathValue("id")
    fmt.Fprintf(w, "Fetching item with ID: %s\n", id)
}

func listItems(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Listing all items\n")
}

func createItem(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Creating a new item\n")
}

PathValue: Your Key to the Wildcard Data

The captured values from your wildcards ({id}, {category}, etc.) are stored in a map within the http.Request and accessed via r.PathValue(key). This is brilliantly simple. No more mux.Vars(r) or other framework-specific nonsense. It’s just there on the standard request object.

A crucial detail: the value returned is always a string. If your {id} is supposed to be an integer, the onus is on you, the programmer (shocking, I know), to parse it using strconv.Atoi. This is the Go way—explicit and safe.

func updateItem(w http.ResponseWriter, r *http.Request) {
    idStr := r.PathValue("id")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid ID: must be an integer", http.StatusBadRequest)
        return // Always return after an error!
    }
    fmt.Fprintf(w, "Updating item with integer ID: %d\n", id)
}

The Trailing Ellipsis Wildcard: {path…}

This one is for when you need to match an entire sub-path, not just a single segment. The pattern /files/{path...} will match /files/images/cats/jpeg, /files/readme.txt, or just /files/. The captured value for path will be the entire trailing path, "images/cats/jpeg".

This is incredibly useful for serving static file trees or for proxy routes. But a word of caution: be very specific about where you use this. A pattern like /{path...} at the root will literally match every single request your server receives and might accidentally shadow all your other, more specific routes. Register these catch-all routes last.

mux.HandleFunc("GET /images/{path...}", serveImage)
// This should be one of the last routes registered

func serveImage(w http.ResponseWriter, r *http.Request) {
    path := r.PathValue("path")
    fmt.Fprintf(w, "Serving image from path: %s\n", path)
}

Precedence and Specificity: The Router’s Secret Rules

The new ServeMux has a well-defined set of rules for which pattern wins when multiple could match. It’s not the order of registration anymore (hallelujah!). It picks the most specific pattern.

  1. Method-specific patterns beat method-agnostic ones. So GET /items is more specific than just /items.
  2. Literal paths beat wildcard paths. So /items/new is more specific than /items/{id}.
  3. Earlier wildcards (with a fixed prefix) beat later ones. So /items/{id}/details is more specific than /items/{path...}.

This means you can register your routes in any order and the router will (usually) do the right thing. It’s a massive improvement in predictability. The old “order matters” behavior was a classic foot-gun, and I’m thrilled to see it go.

Common Pitfalls and Sharp Edges

It’s not all rainbows and unicorns. You need to be aware of a few things.

  • Overlap is Still Possible: You can still shoot yourself in the foot. Registering /items/{id} and /items/new is fine. But what about /items/{category} and /items/{id}? The router can’t read your mind. They are equally specific. In this case, it will panic at startup because it’s an ambiguous registration. The solution? Don’t do that. Use distinct paths like /items/category/{category} and /items/id/{id}.
  • The Empty String is a Value: If a request comes in for /items/ (with a trailing slash) and you have a route for /items/{id}, what is the id? It’s an empty string, "". Your code must handle that. Always validate your path values.
  • No Regex Support: This is a conscious design choice. The new patterns are powerful but simple. They don’t support regular expressions within the wildcards. If you need to validate that an {id} is a UUID, you’ll still have to do that in your handler logic after you call r.PathValue("id").

The bottom line? This upgrade is arguably the single biggest quality-of-life improvement to the Go HTTP stack in years. It makes the standard library router actually viable for real-world applications without immediately reaching for a gorilla/mux or chi. It’s simpler, more explicit, and blessedly boring—just the way Go should be. Use it.