Back to blog

Sunday, October 25, 2026

Why Did The Standard HTTP Router Feel Limited For So Long?

Title card for 'Why Did The Standard HTTP Router Feel Limited For So Long?' with a short code example

Introduction

The standard HTTP router felt limited for so long because, before Go 1.22, it was a request multiplexer, not a full router: it matched exact paths and path subtrees, with no method routing and no path wildcards, so every method check and every path parameter had to be written by hand inside handlers. Go 1.22 added method patterns and wildcards to net/http.ServeMux, which closed the most common gap, while leaving the router without a built in middleware chain or other framework features.

Before You Start

  • Handler: a value that serves an HTTP request. In Go, the http.Handler interface has one method, ServeHTTP, which takes a ResponseWriter and a Request. A HandlerFunc is a function with that signature, adapted to the interface.
  • ServeMux: the HTTP request multiplexer in the net/http package. It holds a list of registered patterns and calls the handler for the pattern that most closely matches each incoming request.
  • Pattern: the string passed to ServeMux.Handle or ServeMux.HandleFunc. In Go 1.22, a pattern has the general form [METHOD ][HOST]/[PATH], where each part is optional. Before Go 1.22, a pattern was a path only, with no method part and no wildcards.
  • Method: the HTTP verb of a request, such as GET, POST, or DELETE. A method pattern, such as GET /posts/{id}, matches only requests with that method. A GET pattern also matches HEAD requests, and every other method must match exactly.
  • Path segment: one part of a URL path between slashes. In /posts/234, the segments are posts and 234. A wildcard matches whole segments, never part of a segment.
  • Wildcard: a named placeholder in a pattern, written {id} for one segment, or {path...} for the rest of the path. The matched value is read with Request.PathValue, for example r.PathValue("id").
  • Subtree pattern: a pattern ending in a slash, such as /posts/, which matches every path beginning with that string. Before Go 1.22, this was the only way to match a group of paths with one registration.
  • Precedence: the rule that decides which handler serves a request when two or more patterns match it. In Go 1.22, the most specific pattern wins, where more specific means matching a strict subset of requests.
  • Middleware: code that wraps a handler, running before or after it, for work such as logging or authentication. ServeMux has no middleware registration of its own. Middleware in Go is ordinary handler wrapping, done by hand.
  • Framework: a larger web package that adds routing, middleware chains, binding, validation, and other features around handlers. A router is one part of a framework, not the whole framework.
  • Third party router: a router from outside the standard library, added as a dependency, that offered method routing and path parameters before Go 1.22 did.

Prerequisites

  • Go 1.22 or later, because the complete example uses method patterns, wildcards, and PathValue, which arrived in Go 1.22. The older shape in this article is labelled as pre Go 1.22, and it is shown for comparison, not as the example baseline.
  • A terminal, and a text editor.
  • No third party packages are required for the complete example.
go version

Requires: Go 1.22 or later, against the official sources in References.

Project Layout

router-demo/
├── go.mod        # module declaration, Go 1.22
├── main.go       # store, handlers, routes, and server entry point
└── main_test.go  # routing, method, precedence, and not found tests
  • go.mod: declares the module and the Go 1.22 baseline, so the new pattern syntax is available.
  • main.go: holds the store and the routes, so the new pattern shape can be read in one place.
  • main_test.go: holds tests that prove the routes, the method rule, and the precedence rule, using net/http/httptest.

What ServeMux Was Before Go 1.22

Before Go 1.22, a ServeMux pattern was a path, and only a path. A pattern without a trailing slash matched that exact path. A pattern with a trailing slash matched a subtree, meaning every path beginning with that string. There was no way to say, in the pattern, that a route was for GET only, or that one segment of the path was a parameter.

That left three jobs to the handler:

  • Method checking: a handler registered for /posts/ received every method for every path under /posts/, so it had to check r.Method itself, and return a method error itself when the method was wrong.
  • Path parsing: a handler registered for /posts/ received /posts/234, /posts/latest, and /posts/234/comments, so it had to strip the prefix, split the remainder, and decide what the segments meant, itself.
  • Exact path guarding: a handler registered for / received every path, because / is a subtree pattern for the whole site, so a home page handler had to check that r.URL.Path was exactly /, or it would serve the home page for every unknown URL.

The Go team's own routing post gives the same example, and the same warning. Before Go 1.22, handling a post started with a line like http.HandleFunc("/posts/", handlePost), and handlePost had to check the method, extract the identifier, and fetch the post. Because the method check was not strictly necessary to satisfy a GET request, it was a natural mistake to omit it, and then a DELETE request to /posts/234 would fetch the post, which is surprising at the least.

None of this meant ServeMux was broken. It meant ServeMux was small. It was a multiplexer: a table from paths to handlers, with a longest match rule for overlapping patterns, plus path sanitizing and trailing slash redirection. A multiplexer answers one question, which handler matches this path, and it answered that question well. It did not answer the questions a router answers, which method, and which segment is a parameter, so projects that needed those answers added a third party router, or wrote a dispatcher by hand inside a handler.

That is why the limit lasted so long in practice. The standard library kept ServeMux small and stable, existing patterns had to keep working, and the new pattern language had to fit on top of the old one without breaking it. The Go 1.22 routing post says the team studied many third party web frameworks, took the most used features, and validated the design with the community, before adding method matching and wildcards to the standard library.

The Old Shape, Shown For Comparison

The following snippet is the pre Go 1.22 shape, shown for comparison. It is not a full file, and it is not the complete example. It still registers under Go 1.22, because the old pattern form still works, but it does not use the new pattern syntax.

mux.HandleFunc("/posts/", func(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    id := strings.TrimPrefix(r.URL.Path, "/posts/")
    if id == "" || strings.Contains(id, "/") {
        http.NotFound(w, r)
        return
    }
    // Fetch the post with this id, and write it as JSON.
})

Read what this handler carries, beyond fetching a post:

  • A method check, with a status code chosen by hand, and no automatic Allow header listing the methods that are allowed.
  • A prefix strip, with a check for an empty remainder, and a check for extra segments, because /posts/ matches more than one post path.
  • A decision about what to do with /posts/latest, which this code would treat as a post with id latest, unless a separate check is added before it.

Every route in a service needed the same three checks, written again, with the same chances to forget one. A forgotten method check is a correctness bug, and a forgotten segment check is a routing bug, and both are silent until a request arrives that exposes them.

What Go 1.22 Changed

Go 1.22 added two enhancements to ServeMux, and the Go team's routing post names them directly: method matching and wildcards. The change almost entirely affects the pattern string passed to Handle and HandleFunc. The only API changes are two new methods on Request for working with wildcard matches, PathValue and SetPathValue.

Method patterns

A pattern can now start with a method, followed by a space, followed by the path:

mux.HandleFunc("GET /posts", listPosts)
mux.HandleFunc("POST /posts", createPost)
mux.HandleFunc("GET /posts/{id}", getPost)
mux.HandleFunc("DELETE /posts/{id}", deletePost)

A pattern with no method still matches every method, so old registrations keep their meaning. A pattern with GET also matches HEAD, and every other method must match exactly. When a request path matches a pattern, but the method does not match any pattern for that path, ServeMux replies with 405 Method Not Allowed, and lists the available methods in an Allow header, following HTTP semantics. The handler no longer has to check the method, and no longer has to build that response by hand.

Wildcards

A wildcard is a full path segment, written in braces, with a name that must be a valid Go identifier:

mux.HandleFunc("GET /posts/{id}", getPost)
mux.HandleFunc("GET /files/{path...}", serveFile)
mux.HandleFunc("GET /posts/{$}", listPosts)
  • {id} matches one segment, and only one segment. It matches /posts/234, but not /posts/234/comments, and not /posts/, because an empty segment is not a segment.
  • {path...} matches the rest of the path, including slashes, and it must be the last segment of the pattern. It matches /files/docs/go/spec.txt with path bound to docs/go/spec.txt.
  • {$} matches only the end of the URL. /posts/{$} matches /posts/ and not /posts or /posts/234. It fixes the old home page problem, where / matched every path, without a path check inside the handler.
  • A trailing slash still acts as an anonymous ... wildcard, so /posts/ still matches a subtree, exactly as before.

The matched values are read from the request, not from a context key that must be remembered:

id := r.PathValue("id")

PathValue returns a string, and it returns the empty string for a name that was never in the pattern, so a handler must still validate the value, as it would any untrusted input. A wildcard gives the handler a named segment, not a validated identifier.

Precedence, most specific wins

When two or more patterns match a request, the most specific pattern wins. More specific means matching a strict subset of requests, in the sense that the other pattern matches every request the first one does, and more.

mux.HandleFunc("GET /posts/{id}", getPost)
mux.HandleFunc("GET /posts/latest", latestPost)

A request for /posts/latest matches both patterns, but /posts/latest matches one path, while /posts/{id} matches many, so the literal pattern wins, regardless of registration order. The same rule covers methods: GET /posts/{id} is more specific than /posts/{id}, because the first matches GET and HEAD only, while the second matches any method.

When neither pattern is more specific, the patterns conflict, and registering the second one panics. For example, /posts/{id} and /{resource}/latest both match /posts/latest, and neither is a subset of the other, so they conflict. That panic happens at registration, usually at server startup, not at request time. There is one exception, for backwards compatibility: when two patterns would otherwise conflict, and one has a host while the other does not, the pattern with the host wins.

This rule generalizes the old longest wins rule. For patterns without wildcards, overlaps only happen when one pattern is a prefix of the other, and the longer one is the more specific one, so old registrations keep their old winners.

Compatibility, and the one edge to know

The new pattern syntax is a superset of the old, and old code continues to work, with a small number of edge cases. The ServeMux documentation lists them, and the one that matters for migration is braces: in Go 1.21, braces in a pattern were ordinary literal segments, so /{x} matched only the path /{x}, while in Go 1.22 it matches any one segment path. To restore the old behavior, the GODEBUG setting httpmuxgo121=1 is available, and it is read once, at program startup. In Go 1.22, syntactically invalid patterns also panic at registration, where Go 1.21 accepted almost every pattern unless it was empty or conflicted.

What Go 1.22 Did Not Change

Go 1.22 closed the routing gap, not the framework gap. The Go team's routing post says this directly: adding these features to the standard library means one fewer dependency for many projects, but third party web frameworks remain a fine choice for current users or programs with advanced routing needs.

What ServeMux still does not offer, as a router:

  • No middleware chain: ServeMux has no method to register middleware for a route, or for a group of routes. Its methods are Handle, HandleFunc, Handler, and ServeHTTP, plus NewServeMux. Middleware is done by wrapping handlers, as in the complete example, where a logging wrapper takes a handler and returns a handler. That works, and it is the standard Go idiom, but the wrapping, the order, and the per route choice are all written by hand.
  • No request binding or validation: a wildcard value is a string, and the handler must convert it, check it, and return a 400 or 404 itself. There is no route level type conversion, no required parameter rule, and no validation rule in the pattern.
  • No route groups: there is no group object that shares a prefix, a middleware set, or a host for a set of routes. Shared prefixes are repeated in each pattern, or built by a helper that registers routes with a prefix, written by hand.
  • No built in response helpers beyond the package: JSON encoding, error bodies, and content negotiation are still handler code, using encoding/json and http.Error, as in the complete example.

None of these are missing pieces of the router. They are features of a framework, and ServeMux is not a framework. The distinction matters for the decision at the end of this article: a service that needs routing plus a middleware chain, binding, and groups is choosing a framework, not asking ServeMux to become one.

Design Decisions And Tradeoffs

OptionUse whenCost
Standard ServeMux, Go 1.22 patternsRoutes are method plus path patterns, with a small number of path parametersNo middleware chain, no binding, no route groups, all written by hand
Pre Go 1.22 ServeMux shapeA codebase must stay on an older Go version, or old patterns must keep their exact meaningMethod checks, path parsing, and exact path guards inside every handler
Third party routerRouting needs go beyond method and wildcard patterns, or a framework is wantedA dependency, its own pattern rules, and its own upgrade path
Hand written dispatcherRoutes need logic that no pattern language expresses, such as a database lookup per requestEvery routing decision is code, with no pattern table to read
Middleware by wrappingLogging, authentication, or recovery around handlersWrapping order and per route choice are written by hand, and can be missed on a new route
Literal pattern over wildcardOne path in a wildcard set needs its own handler, such as /posts/latestThe literal must be kept in step with the wildcard set, or it shadows a real parameter value
Multi segment wildcardA route serves a tree, a file path, or a downstream pathIt matches the rest of the path, so a more specific route must be registered for paths it must not swallow

A design in this article has a deliberate compromise, stated openly. The complete example uses an in memory store with a slice of posts, not a database, so the routing, method, and precedence rules can be read without storage code. The store is not a production store. The routing rules themselves are not compromised: patterns, methods, wildcards, and precedence are all taken from the ServeMux documentation.

Complete Example

The complete example is a small posts service. It has a list route, a create route, a get route, and a latest route, so the precedence rule can be seen in a real registration: /posts/latest must win over /posts/{id} for the path /posts/latest.

// go.mod
module routerdemo

go 1.22
// main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strings"
    "sync"
)

type Post struct {
    ID    string `json:"id"`
    Title string `json:"title"`
}

type Store struct {
    mu    sync.Mutex
    posts []Post
    next  int
}

func newStore() *Store {
    return &Store{next: 1}
}

func writeJSON(w http.ResponseWriter, status int, value any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(value)
}

func (s *Store) list(w http.ResponseWriter, r *http.Request) {
    s.mu.Lock()
    defer s.mu.Unlock()
    items := append([]Post(nil), s.posts...)
    writeJSON(w, http.StatusOK, items)
}

func (s *Store) get(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    s.mu.Lock()
    defer s.mu.Unlock()
    for _, post := range s.posts {
        if post.ID == id {
            writeJSON(w, http.StatusOK, post)
            return
        }
    }
    http.NotFound(w, r)
}

func (s *Store) create(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title string `json:"title"`
    }
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    if strings.TrimSpace(input.Title) == "" {
        http.Error(w, "title is required", http.StatusBadRequest)
        return
    }
    s.mu.Lock()
    defer s.mu.Unlock()
    post := Post{ID: fmt.Sprintf("%d", s.next), Title: input.Title}
    s.next++
    s.posts = append(s.posts, post)
    writeJSON(w, http.StatusCreated, post)
}

func (s *Store) latest(w http.ResponseWriter, r *http.Request) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if len(s.posts) == 0 {
        http.NotFound(w, r)
        return
    }
    writeJSON(w, http.StatusOK, s.posts[len(s.posts)-1])
}

func withLogging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

func newMux(store *Store) *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /posts/latest", store.latest)
    mux.HandleFunc("GET /posts/{id}", store.get)
    mux.HandleFunc("GET /posts/{$}", store.list)
    mux.HandleFunc("POST /posts/{$}", store.create)
    return mux
}

func main() {
    store := newStore()
    handler := withLogging(newMux(store))
    log.Fatal(http.ListenAndServe(":8080", handler))
}

Commands to run it, from the router-demo folder:

go run .

Expected requests and responses, with the server running on port 8080:

curl -X POST localhost:8080/posts/ -d '{"title":"First"}'
curl localhost:8080/posts/
curl localhost:8080/posts/1
curl localhost:8080/posts/latest
curl -X DELETE localhost:8080/posts/1
{"id":"1","title":"First"}
[{"id":"1","title":"First"}]
{"id":"1","title":"First"}
{"id":"1","title":"First"}
405 Method Not Allowed

Why these responses follow from the patterns, in order:

  • POST /posts/{$} matches only the path /posts/, so the create route does not also accept /posts/1.
  • GET /posts/{$} lists every post, and GET /posts/{id} fetches one post by its id, read with PathValue.
  • GET /posts/latest wins over GET /posts/{id} for /posts/latest, because the literal pattern is more specific than the wildcard pattern.
  • DELETE /posts/1 matches the path of a GET pattern, but no DELETE pattern, so ServeMux replies with 405, not 404, and the handler never runs.

Testing Routes

Routing is tested with net/http/httptest, which builds a request and a recorder, calls the mux, and lets the test read the status and body, without a real server or a real port.

// main_test.go
package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func createPost(t *testing.T, mux *http.ServeMux, title string) Post {
    t.Helper()
    req := httptest.NewRequest(http.MethodPost, "/posts/", strings.NewReader(`{"title":"`+title+`"}`))
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    if rec.Code != http.StatusCreated {
        t.Fatalf("create %s: status = %d, want %d", title, rec.Code, http.StatusCreated)
    }
    var post Post
    if err := json.NewDecoder(rec.Result().Body).Decode(&post); err != nil {
        t.Fatalf("create %s: decode error: %v", title, err)
    }
    return post
}

func TestListCreateAndGet(t *testing.T) {
    mux := newMux(newStore())
    created := createPost(t, mux, "First")
    if created.ID != "1" {
        t.Fatalf("created id = %q, want %q", created.ID, "1")
    }
    req := httptest.NewRequest(http.MethodGet, "/posts/1", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    if rec.Code != http.StatusOK {
        t.Fatalf("get status = %d, want %d", rec.Code, http.StatusOK)
    }
    var got Post
    if err := json.NewDecoder(rec.Result().Body).Decode(&got); err != nil {
        t.Fatalf("get decode error: %v", err)
    }
    if got.Title != "First" {
        t.Errorf("get title = %q, want %q", got.Title, "First")
    }
    req = httptest.NewRequest(http.MethodGet, "/posts/", nil)
    rec = httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    if rec.Code != http.StatusOK {
        t.Fatalf("list status = %d, want %d", rec.Code, http.StatusOK)
    }
    var items []Post
    if err := json.NewDecoder(rec.Result().Body).Decode(&items); err != nil {
        t.Fatalf("list decode error: %v", err)
    }
    if len(items) != 1 {
        t.Errorf("list length = %d, want 1", len(items))
    }
}

func TestMethodNotAllowed(t *testing.T) {
    mux := newMux(newStore())
    createPost(t, mux, "First")
    req := httptest.NewRequest(http.MethodDelete, "/posts/1", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    if rec.Code != http.StatusMethodNotAllowed {
        t.Errorf("delete status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
    }
}

func TestLatestBeatsWildcard(t *testing.T) {
    mux := newMux(newStore())
    createPost(t, mux, "First")
    createPost(t, mux, "Second")
    req := httptest.NewRequest(http.MethodGet, "/posts/latest", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    if rec.Code != http.StatusOK {
        t.Fatalf("latest status = %d, want %d", rec.Code, http.StatusOK)
    }
    var got Post
    if err := json.NewDecoder(rec.Result().Body).Decode(&got); err != nil {
        t.Fatalf("latest decode error: %v", err)
    }
    if got.Title != "Second" {
        t.Errorf("latest title = %q, want %q", got.Title, "Second")
    }
}

func TestNotFound(t *testing.T) {
    mux := newMux(newStore())
    req := httptest.NewRequest(http.MethodGet, "/posts/999", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    if rec.Code != http.StatusNotFound {
        t.Errorf("missing post status = %d, want %d", rec.Code, http.StatusNotFound)
    }
}

The list, create, and get test checks the full route set: a post is created, fetched by id, and listed. The method test checks the 405 rule, not a handler check, because no DELETE pattern is registered. The latest test checks precedence directly: if the wildcard won, the handler would look for a post with id latest, and return 404, instead of the last post. The not found test checks that an unknown id returns 404, not an empty post.

Commands to run the tests and the static check:

go vet ./...
go test ./...

Expected output for the tests is a pass for every test, including the method and precedence tests, because those rules live in ServeMux, not in handler code. go vet reports suspicious constructs. It does not check route precedence, so do not treat a clean vet run as proof that the routes are right. The tests above are the proof for this example.

Common Mistakes

  • Treating / as a home page only: a pattern of / matches every path, because it is a subtree pattern. The fix is /{$}, which matches only /, or a path check inside the handler for the old shape.
  • Forgetting that a subtree matches more than one path: a handler registered for /posts/ receives /posts/234, /posts/latest, and /posts/234/comments. The fix, in Go 1.22, is a wildcard or {$} pattern that says exactly which paths the handler serves.
  • Checking the method inside a Go 1.22 handler, and getting it wrong: a method pattern already guarantees the method, apart from GET also matching HEAD. The fix is to put the method in the pattern, and to let ServeMux return 405 for other methods.
  • Using a wildcard for part of a segment: a wildcard must be a full segment, so /b_{bucket} is not a valid pattern, and it panics at registration in Go 1.22. The fix is to make the wildcard a whole segment, such as /b/{bucket}.
  • Putting a multi segment wildcard anywhere but last: {path...} must be the last segment of the pattern, or registration panics. The fix is to move it to the end, or to use single segment wildcards for the middle of the path.
  • Assuming registration order decides the winner: ServeMux is order independent, and the most specific pattern wins, so registering the wildcard first does not make it win. The fix is to check specificity, not order, and to add a literal pattern for a path that must win.
  • Registering two patterns that conflict: when neither pattern is more specific, such as /posts/{id} and /{resource}/latest, registration panics. The fix is to make one pattern more specific, or to change one pattern so the overlap goes away.
  • Trusting a wildcard value without validation: PathValue returns a string, and it returns the empty string for a name that was never in the pattern. The fix is to convert and validate the value, and to return 400 or 404 for a value that does not parse or does not exist.
  • Expecting 404 for a wrong method: when the path matches but the method does not, ServeMux returns 405, with an Allow header, not 404. The fix is to test for 405 in the method test, and to read the Allow header when debugging a method failure.
  • Expecting middleware from ServeMux: ServeMux has no middleware registration, so a logging or authentication wrapper that is applied to the mux in main is not applied to a handler registered elsewhere, or to a test mux built without it. The fix is to apply the wrapper in one place, in the function that builds the handler for the server, and to test the mux and the wrapper separately.
  • Mixing old and new patterns without checking braces: in Go 1.21, braces were literal segments, while in Go 1.22 they are wildcards. The fix is to check every old pattern that contains a brace, and to use the httpmuxgo121 setting only as a migration step, not as a permanent fix.

Production Notes

  • Shutdown: a server built with http.Server should be shut down with Shutdown, with a context, so in flight requests finish, instead of closing the listener at once. Routing does not change that rule.
  • Observability: route registration panics happen at startup, which is a good place for a routing bug to surface, because the service fails before it serves traffic. A conflicting pattern that is only registered in one code path, such as a feature flag, will not panic until that path runs, so register every route at startup, in one function.
  • Timeouts: ServeMux does not set request timeouts. An http.Server with read, write, and idle timeouts, as in the package overview, bounds how long a request can hold a connection, regardless of which route serves it.
  • Resource limits: a multi segment wildcard can match a long path, and a handler that reads the whole body without a limit can be abused. Use http.MaxBytesReader for request bodies with a size limit, and validate wildcard values before using them as file paths or database keys.
  • Deployment: the example needs Go 1.22 or later, pinned in go.mod. Run go vet ./... and go test ./... in continuous integration, and do not deploy a change that fails the method or precedence tests. A vet pass alone is not enough for routing, because vet does not prove that the right handler serves a given path.

Decision Guide

  • When routes are method plus path patterns, with a small number of parameters: use the standard ServeMux with Go 1.22 patterns, and wrap handlers by hand for logging or authentication.
  • When a codebase must stay on a Go version before 1.22: use the old ServeMux shape, with method checks and path parsing inside handlers, and accept that cost, or add a third party router.
  • When routing needs a middleware chain, route groups, or binding: choose a third party router or framework, and compare its pattern rules with ServeMux, instead of stretching ServeMux into a framework.
  • When one path in a wildcard set needs its own handler: register a literal pattern for that path, and test that it wins, as in the latest test above.
  • When a route serves a file tree or a downstream path: use a multi segment wildcard, and register more specific routes first in reading order, for paths it must not swallow, remembering that registration order does not decide precedence.
  • When a route must match only one exact path: use {$} for a path with a trailing slash, or a pattern without a trailing slash for a path without one, and test the near misses, such as the path with and without the slash.

Conclusion

The standard router felt limited for so long because it was built as a multiplexer, not as a router in the modern sense. It matched paths, and it matched them well, but method routing and path parameters lived in handler code, or in a third party router, for every project that needed them.

Summary Of Key Points

  • Before Go 1.22, patterns were paths only: exact paths and subtree patterns, with no method part and no wildcards.
  • Handlers carried the routing work: method checks, prefix stripping, segment checks, and exact path guards, all written by hand, with a real chance to forget one.
  • Go 1.22 added method patterns and wildcards: GET /posts/{id}, {id} for one segment, {path...} for the rest, and {$} for an exact end.
  • Precedence is most specific wins: a literal beats a wildcard, a method pattern beats a method free pattern, and a true conflict panics at registration.
  • A wrong method gives 405, not 404: ServeMux replies with 405 and an Allow header when the path matches but the method does not.
  • ServeMux is still not a framework: no middleware chain, no binding, no route groups, so a service that needs those is choosing a framework, not a better router.

Use ServeMux when method and path patterns are the whole routing problem, and reach for a framework when the problem grows past routing. That line, more than any single feature, is what Go 1.22 made possible to draw.

Additional Resources

  • Go Blog: Routing Enhancements for Go 1.22 - method matching, wildcards, precedence, and compatibility, from the Go team
  • Go Blog: Go 1.22 is released! - the release announcement, naming ServeMux method and wildcard patterns as a standard library addition
  • Go Packages: net/http package - ServeMux patterns, precedence, trailing slash redirection, request sanitizing, and compatibility
  • Go Documentation: Go 1.22 Release Notes - the full release notes for Go 1.22, including the ServeMux pattern change

References

  1. Routing Enhancements for Go 1.22. Go team, Go Blog. https://go.dev/blog/routing-enhancements. Accessed 2026-10-25.
  2. Go 1.22 is released! Go team, Go Blog. https://go.dev/blog/go1.22. Accessed 2026-10-25.
  3. net/http package. Go team, Go Packages. https://pkg.go.dev/net/http#ServeMux. Accessed 2026-10-25.
  4. Go 1.22 Release Notes. Go team, Go Documentation. https://go.dev/doc/go1.22. Accessed 2026-10-25.