How to Use the New ServeMux in Go 1.22+ (Method Matching, Wildcards)

Web
Use http.NewServeMux() with method-prefixed patterns and wildcard handlers to route HTTP requests in Go 1.22+.

The routing problem before Go 1.22

You spend twenty minutes writing a Go HTTP handler. You test it with curl. It works. You add a second route. Suddenly the first route stops responding. You realize the old http.ServeMux treats every pattern as a prefix match. GET /api/items and GET /api/items/1 both hit the same handler because the legacy router only looks at the longest matching prefix. You end up writing manual string splitting inside every handler just to figure out what the client actually wanted. Go 1.22 fixed this. The standard library router now understands HTTP methods, supports wildcards, and extracts path variables without you writing a single regex.

The standard library finally catches up to the frameworks.

What changed in the standard library

Think of routing like a postal sorting facility. The old system was a single conveyor belt that dropped every letter starting with "New York" into the same bin. You had to open every envelope to see if it was for Brooklyn, Queens, or Manhattan. The new system is a set of automated scanners. It reads the destination zip code, checks the service type, and routes it to the exact delivery truck.

Under the hood, Go replaced the old prefix-matching logic with a radix tree. A radix tree is a compact trie that shares common prefixes among routes. It lets the router walk the URL path character by character, matching methods and wildcards in linear time. You get the speed of the standard library with the precision of a third-party framework. The router now distinguishes between exact matches, path variables, and catch-all wildcards. It also filters by HTTP method before it ever calls your handler.

The router walks the tree. You write the handler.

A minimal router that actually works

Here is the simplest router that uses method-specific patterns and a static file wildcard.

package main

import (
	"net/http"
)

func main() {
	// NewServeMux allocates the updated router that supports method matching and wildcards.
	mux := http.NewServeMux()

	// Prefixing the pattern with GET ensures POST or PUT requests return 405 Method Not Allowed.
	mux.HandleFunc("GET /api/items", func(w http.ResponseWriter, r *http.Request) {
		// Write sends the response body directly to the client without flushing headers.
		w.Write([]byte("list items"))
	})

	// The trailing slash wildcard matches any path starting with /static/.
	mux.Handle("/static/", http.FileServer(http.Dir("./static")))

	// ListenAndServe blocks the main goroutine until the process exits.
	http.ListenAndServe(":8080", mux)
}

The code above does three things. It creates a router. It registers a method-specific route. It registers a wildcard route for static assets. You run it with go run . and hit http://localhost:8080/api/items. The router matches the GET method and the exact path. You hit http://localhost:8080/static/logo.png and the wildcard hands the request to http.FileServer. No string parsing. No manual method checks.

Trust the pattern syntax. Let the router do the heavy lifting.

How the new mux matches requests

When a request arrives, the router parses the URL path and walks the radix tree from root to leaf. It compares each segment against the registered patterns. If it finds an exact match, it checks the HTTP method. If the method matches, it calls the handler. If the method does not match, it returns a 405 Method Not Allowed response with a Allow header listing the valid methods. If the path does not match any registered pattern, it returns 404 Not Found.

The router prefers exact matches over prefix matches. This is a deliberate design choice. The old http.DefaultServeMux relied on longest-prefix matching, which caused subtle bugs when routes overlapped. The new router treats /api/items and /api/items/ as completely different patterns. You must be explicit about trailing slashes. The compiler does not warn you about routing logic, but the runtime will reject requests that do not align with your patterns.

Path variables use curly braces. A pattern like /users/{id} captures any non-empty string segment. The router stores the captured value in the request context. You retrieve it with r.PathValue("id"). Wildcards use an asterisk. A pattern like /files/* captures the entire remaining path, including slashes. You retrieve it with r.PathValue("*"). The distinction matters. Path variables match a single segment. Wildcards match everything that follows.

The router walks the tree. You write the handler.

Realistic API setup with path variables

Here is a realistic endpoint that extracts a user ID from the URL and respects request cancellation.

// GetUser fetches a user by ID from the URL path.
func GetUser(w http.ResponseWriter, r *http.Request) {
	// PathValue extracts the named variable defined in the route pattern.
	id := r.PathValue("id")

	// Context carries deadlines and cancellation signals across the call chain.
	ctx := r.Context()

	// Check if the client disconnected or a deadline was exceeded.
	if ctx.Err() != nil {
		http.Error(w, "request cancelled", http.StatusServiceUnavailable)
		return
	}

	// Fprintf formats the response and writes it to the ResponseWriter.
	fmt.Fprintf(w, "user %s", id)
}

Here is the main function that wires the handler to the router.

func main() {
	// NewServeMux creates the radix-tree router with method and path support.
	mux := http.NewServeMux()

	// The {id} placeholder captures any non-empty string segment in the URL.
	mux.HandleFunc("GET /users/{id}", GetUser)

	// ListenAndServe starts the HTTP server and blocks until shutdown.
	http.ListenAndServe(":8080", mux)
}

The handler follows standard Go conventions. The function name starts with a capital letter because it is exported. The receiver is implicit in the http.HandlerFunc adapter. The context is pulled from the request, not passed as a separate parameter, because http.Request already carries it. You check ctx.Err() before doing expensive work. You return early on cancellation. You format the response and write it. The boilerplate is intentional. It makes the unhappy path visible.

Extract the variable. Check the context. Return the data.

Common mistakes and compiler warnings

Developers transitioning from third-party routers often trip over pattern syntax. The compiler catches type mismatches immediately. If you pass a handler directly to HandleFunc instead of a pattern string, the compiler rejects the program with cannot use func(http.ResponseWriter, *http.Request) as string value in argument. HandleFunc expects a pattern string as its first argument. Handle expects a pattern string and an http.Handler as its arguments.

Path variable syntax is strict. You cannot use curly braces without a preceding slash. A pattern like /users{id} triggers a runtime panic during registration with pattern /users{id} must not have a variable without a preceding slash. The router requires clear segment boundaries. You must write /users/{id} instead.

Trailing slashes matter. /api/items and /api/items/ are different routes. The router does not automatically redirect between them. If you register /api/items and a client requests /api/items/, the router returns 404 Not Found. You must register both patterns if you want to support both URLs, or use a wildcard like /api/items/* to catch the trailing slash.

Method filtering only works when you prefix the pattern. If you register /api/items without GET , the router treats it as a prefix match and accepts all methods. You lose the automatic 405 response. You end up writing manual if r.Method != http.MethodGet checks inside every handler. The community accepts the boilerplate because it makes the unhappy path visible. Prefix the pattern to let the router enforce the contract.

Test the 405 response before you deploy.

When to use the new ServeMux

Use the new http.ServeMux when you want standard library routing with method matching and path variables. Use a third-party router like chi or gin when you need middleware chaining, request binding, or advanced routing features. Use manual prefix matching when you are maintaining legacy code that relies on http.DefaultServeMux. Use a reverse proxy when you need to route traffic to multiple backend services without writing Go handlers.

Pick the tool that matches your routing complexity.

Where to go next