A long-awaited upgrade to Go’s HTTP router

Go 1.22 is set to bring a significant enhancement to the standard library’s HTTP serving multiplexer (http.ServeMux). The proposal behind this change addresses a long-standing gap: for years, the built-in router only supported rudimentary path matching, which pushed many developers toward third-party routing packages. The new multiplexer aims to close much of that distance by introducing method-based routing and path wildcards directly into the standard library.

What’s new in pattern matching

The updated ServeMux introduces two headline features that will be immediately familiar to anyone who has used a package like gorilla/mux:

  • Method-specific patterns: You can now specify an HTTP method (e.g., GET) as part of the pattern itself. A handler registered with GET /path/ will only fire for GET requests, not for other verbs.
  • Path wildcards: A single path component can be captured with a {id} style wildcard. The matched value is then retrieved inside the handler via req.PathValue("id").

Consider this basic registration example:

package main

import (
  "fmt"
  "net/http"
)

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("GET /path/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "got path\n")
  })

  mux.HandleFunc("/task/{id}/", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "handling task with id=%v\n", id)
  })

  http.ListenAndServe("localhost:8090", mux)
}

When a request comes in, the multiplexer routes based on both the method and the path shape. A POST to /path/ will be rejected, while the same path with GET proceeds. A request to /path/123 matches the wildcard pattern, and the handler can read the captured id value directly from the request.

The documentation also covers more advanced matching capabilities, such as trailing wildcards ({id}...) and strict path-end matching with {$}. These additions give the standard library a level of expressiveness that was previously only available through external routers.

Handling pattern conflicts

With more expressive patterns comes the potential for ambiguity. If two patterns can match the same request, the new ServeMux applies a well-defined set of precedence rules to resolve the tie. When no precedence rule can disambiguate, registration panics with a detailed and actionable error message. This is particularly valuable in large codebases where patterns may be registered across multiple files or packages.

For example, registering overlapping patterns for a task resource produces a panic that explains exactly which patterns conflict and why:

panic: pattern "/task/0/{action}/" (registered at sample-conflict.go:14) conflicts with pattern "/task/{id}/status/" (registered at sample-conflict.go:10):
/task/0/{action}/ and /task/{id}/status/ both match some paths, like "/task/0/status/".
But neither is more specific than the other.
/task/0/{action}/ matches "/task/0/action/", but /task/{id}/status/ doesn't.
/task/{id}/status/ matches "/task/id/status/", but /task/0/{action}/ doesn't.

Revisiting a practical example

To see the impact of the new router, consider a simple task/todo-list server originally built in two parts: first with the vanilla standard library, and then with gorilla/mux. Reimplementing that server with the Go 1.22 multiplexer yields a result that closely mirrors the gorilla/mux version.

The pattern registration becomes method-aware, allowing the same path to be routed to different handlers based on the HTTP verb:

mux := http.NewServeMux()
server := NewTaskServer()

mux.HandleFunc("POST /task/", server.createTaskHandler)
mux.HandleFunc("GET /task/", server.getAllTasksHandler)
mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)

Inside a handler, extracting a path parameter is straightforward:

func (ts *taskServer) getTaskHandler(w http.ResponseWriter, req *http.Request) {
  log.Printf("handling get task at %s\n", req.URL.Path)

  id, err := strconv.Atoi(req.PathValue("id"))
  if err != nil {
    http.Error(w, "invalid id", http.StatusBadRequest)
    return
  }

  task, err := ts.store.GetTask(id)
  if err != nil {
    http.Error(w, err.Error(), http.StatusNotFound)
    return
  }

  renderJSON(w, task)
}

One practical difference from gorilla/mux is that the built-in wildcard syntax does not support regular expressions. If a route requires an integer ID specifically, the handler must convert the captured string with strconv.Atoi and handle the resulting error manually.

Overall, the new standard library router produces handler code that is much cleaner and more decoupled than the old approach, where the handler itself had to inspect the request method and parse the URL. The routing logic now lives where it belongs—in the route definitions.

What this means for Go developers

The question “which router package should I use?” has been a staple for Go beginners. After Go 1.22, the answer will often be the standard library. Many projects will find the built-in multiplexer sufficient, eliminating a dependency for a core piece of web infrastructure.

That said, third-party routers and lightweight frameworks like Gin still have their place. They offer additional features beyond routing, such as middleware ecosystems and extra utilities. For teams already invested in those tools, there is no pressing reason to switch. But the fact that the standard library now covers a substantial share of common routing needs is a clear win for the entire Go community.