Go 1.22 upgraded the standard library's http.ServeMux so it can route by HTTP method and extract path parameters without any third-party packages. Before this change, the built-in router only matched on path prefixes, which is why most Go web apps reached for chi, gorilla/mux, or gin just to handle basic REST routes. This lesson covers the new pattern syntax, how wildcards work, and how to build a small product and order API using nothing but net/http.
Before Go 1.22, http.ServeMux matched patterns in two simple ways. A pattern like "/products" matched that exact path. A pattern ending in / like "/products/" matched anything starting with that prefix. There was no way to say "only match GET requests" or "capture the ID from /products/42". Every handler received every method, and you had to inspect r.Method yourself or pull in a routing library.
Here's what that looked like in practice.
Three problems stack up in that handler. The method check is manual, the ID parsing is manual, and a POST /products/42 would hit the same function as a GET. For a real API with five resources and four methods each, you'd write the same boilerplate twenty times.
The Go team's answer in 1.22 wasn't a brand new router. They extended the existing ServeMux pattern syntax with two features: method prefixes and path wildcards. Existing patterns keep working unchanged, so you can adopt the new syntax route by route.
The first new feature is that a pattern can start with an HTTP method followed by a space. "GET /products" matches a GET request to /products. "POST /products" matches a POST to the same path. You can register both with the same ServeMux and they don't conflict, because the mux now treats the method as part of the pattern's identity.
The space between the method and the path is required, and the method must be uppercase. The mux accepts any method name that's syntactically valid HTTP, so "PATCH /products/{id}" and "OPTIONS /products" work just as well as the common four. If you write a typo like "Get /products" (lowercase g), the mux treats it as a path-only pattern with the literal string "Get" as the first segment, which is almost never what you want.
Hit this server from another terminal and is the two methods routed to different handlers.
The DELETE request returns 405 with no extra code from you. The mux notices that /products has handlers registered for GET and POST, so any other method on the same path is automatically rejected. The response also includes an Allow: GET, POST header, which is what the HTTP spec says a 405 should carry. Older code that wanted this behavior had to maintain its own table of registered methods per path; the mux now does that bookkeeping for you.
A subtle rule: if you register "GET /products" only, then a HEAD request to /products also matches. HEAD is treated as a GET with the body stripped, so the same handler runs. This matches what the HTTP spec expects, and it's why you don't separately register HEAD handlers in most APIs. If your handler writes a body, the framework still drops it on the floor for a HEAD response, but it does send the headers, including Content-Length. That's enough for clients that use HEAD to check if a resource exists or how big it is.
Method routes and path-only routes coexist on the same mux. You can register "GET /products" and "/health" side by side. The first matches only GET requests to /products. The second matches every method on /health, because no method prefix means "any method". That's a useful escape hatch for handlers that genuinely don't care about the verb, like a liveness probe.
The second new feature is wildcards. A segment wrapped in braces, like {id}, matches any single path segment and binds the matched text to a name. Inside the handler, r.PathValue("id") returns the captured value as a string.
{id} matches exactly one segment. So /products/42 and /products/notebook both match, but /products/42/reviews does not, because that's two segments after /products/. The wildcard name is just a label; you can call it {productID} or {name} and the matching rule is identical. The name does have to be a valid Go identifier (letters, digits, underscores, starting with a letter or underscore), since you'll use it as a string key in PathValue.
A wildcard matches at least one character. The path /products/ (with a trailing slash and nothing after it) does not match GET /products/{id}, because {id} would have to be empty. If you want to handle the bare /products/ path, register a separate route for it. This is a common point of confusion when someone moves from gorilla/mux, which lets wildcards match empty strings by default in some configurations.
Wildcards can appear at any segment position, not just the last one. A pattern like GET /customers/{customerID}/orders/{orderID} captures two values, accessible as r.PathValue("customerID") and r.PathValue("orderID").
There's also a trailing wildcard form, {name...}, that captures the rest of the path. This is useful when you want to forward a sub-path to a different handler.
The trailing wildcard can match an empty string, as the second curl shows. If you want to require at least one character, you'll need to check path == "" in the handler and return a 404 yourself.
A third form, {$}, anchors the pattern to an exact match. Without it, a pattern ending in / matches anything starting with that prefix (the old ServeMux behavior, kept for backward compatibility). With {$}, the pattern matches only the exact path.
| Pattern | Matches /products/ | Matches /products/42 | Matches /products/42/reviews |
|---|---|---|---|
/products/ | yes | yes (prefix) | yes (prefix) |
/products/{$} | yes | no | no |
/products/{id} | no | yes | no |
/products/{id}/{$} | no | no | no |
/products/{rest...} | yes (rest is empty) | yes | yes |
That table is worth reading twice. The presence or absence of a trailing slash, the {$} anchor, and the choice between {name} and {name...} give you four distinct matching behaviors. Most APIs only need two or three of them, but knowing all four prevents surprises.
A common case for {$} is when you want / to map to a homepage handler but don't want it to act as a catch-all prefix for unrelated paths. Registering "GET /{$}" matches exactly / and nothing else. Without the anchor, "GET /" matches every path that isn't claimed by a more specific pattern, which makes it a default route. Both behaviors are useful; the anchor lets you pick one without writing a manual length check on r.URL.Path.
Without the {$} anchor on the first route, the homepage handler would also serve /anything-else as a fallback. With the anchor, the mux falls through to the default 404 for unmatched paths.
When more than one pattern could match a request, the mux picks the most specific one. The rule is: a pattern P1 is more specific than P2 if P1 matches a strict subset of the paths that P2 matches. So GET /products/featured beats GET /products/{id}, because the literal segment "featured" is more specific than the wildcard.
/products/featured hits the specific handler even though {id} would also match. The order you register the patterns doesn't matter; the mux compares specificity at lookup time.
What if two patterns are equally specific in a way that creates a real ambiguity? Then registration panics. Before Go 1.22, conflicts were resolved by some implementation detail, which made bugs hard to find. Now you get told at startup.
That conflict happens because /products/featured/reviews would match both patterns and neither is strictly more specific. The mux refuses to guess. To fix it, register a third, more specific pattern (GET /products/featured/reviews) that takes priority over both.
Conflict detection happens at registration time, so it adds no per-request overhead. Routing itself is roughly O(log n) in the number of registered patterns. For typical APIs with a few hundred routes, the cost is negligible compared to handler work.
The diagram below shows how a request flows through the mux, from URL to handler to response, with the new method and wildcard machinery folded in.
The diagram makes the order explicit: the mux only picks one handler, and the specificity comparison runs once per request. After that, your handler code takes over and the mux is done.
A pattern can also start with a host. "example.com/products" matches only requests whose Host header is example.com. Patterns with a host beat patterns without one, all else being equal. This is useful when one server handles multiple domains, but most APIs ignore it and route purely on path.
You can combine all three pieces in one pattern: "GET api.shop.com/products/{id}" matches GET requests to /products/{id} only when the Host header is api.shop.com. Most teams don't use this until they're consolidating several services onto one port, but it's there if you need it. A single mux can route for multiple hosts without any per-vhost configuration outside of pattern strings.
PathValue returns the raw string from the URL, not a parsed type. Each call is cheap (a slice lookup), but parsing the value with something like strconv.Atoi runs once per request. If the same handler reads the same path value multiple times, store the result in a local variable rather than calling PathValue repeatedly.
Putting the new features together, you can write a small product and order API with no dependencies. The code below has six routes covering both resources, plus a 404 fallback.
The handlers are methods on *Store so they share the in-memory data, with a mutex to keep things safe across concurrent requests. JSON encoding and decoding goes through encoding/json from the standard library. The whole file is under 100 lines and depends on nothing outside net/http, encoding/json, strconv, and sync.
A sample run against the server, using curl from another terminal.
That's a working JSON REST API in roughly 80 lines, no external dependencies, no router library, no framework. Five years ago, the same code with gorilla/mux would have been longer and required a go.mod line.
The Store type bundles the data and the mutex, so the concurrency story is local: every handler that touches the maps acquires the mutex first. The handlers are methods on *Store instead of plain functions, which means they all share the same store without globals or closures. writeJSON centralizes the encoder setup and the Content-Type header, so individual handlers don't repeat that boilerplate. None of these patterns are new in Go 1.22; they just become more visible when you stop relying on a router framework to bring its own conventions.
For testing, the net/http/httptest package pairs well with the new mux. You don't need to start a real TCP listener to verify routing behavior; you can build an httptest.NewRequest, hand it to mux.ServeHTTP along with an httptest.NewRecorder, and inspect what the mux did. Most of the exercises at the end of this lesson use that pattern.
The new mux is enough for a lot of APIs, but it isn't a full-featured router and won't ever be. A few things it leaves out:
mux.Use(authMiddleware). You wrap handlers manually: mux.Handle("GET /products", authMiddleware(http.HandlerFunc(listProducts))). For a handful of middlewares this is fine, but if you want a fluent chain like r.Use(logger, auth, ratelimit), you'll still want chi or similar.{id} matches any segment, including notebook. If you need "id must be a positive integer", you check it inside the handler. Routers like chi and gin support regex constraints in patterns; the standard mux does not./api/v1/ uses this middleware and this prefix". You repeat the prefix in each registration. A http.StripPrefix helper plus a sub-mux is the standard workaround.reverse("product_detail", id=42) to get back /products/42. Go's mux has no concept of route names, so URL generation is your job.There's also no built-in request body size limit, no built-in CORS handling, and no built-in content negotiation. These are middleware concerns in most frameworks, and they're middleware concerns here too. The http.MaxBytesReader helper covers body size, the rs/cors package or a hand-written middleware handles CORS, and content negotiation is usually a few lines around r.Header.Get("Accept"). the mux focuses on routing and leaves these orthogonal concerns alone, which is consistent with the rest of the standard library's "small, composable pieces" style.
The table below shows where the standard mux now stands against two popular third-party routers.
| Feature | http.ServeMux (1.22+) | chi | gorilla/mux |
|---|---|---|---|
| Method-based routing | yes | yes | yes |
| Path wildcards | yes | yes | yes |
| Regex constraints on wildcards | no | yes | yes |
| Middleware chain helpers | no | yes | yes |
| Route groups with prefix | no | yes | yes |
| Named routes / URL reversal | no | no | yes |
| External dependency | no | yes | yes |
For internal services, prototypes, and APIs without complex auth chains, the standard mux is often enough. For a public-facing API with rate limiting, auth scopes, and observability middleware, you'll likely still pull in chi. The win in Go 1.22 is that the choice is now a real choice, not a "you have to use a library" forgone conclusion.
A small workaround for the missing middleware chain looks like this.
You wrap the whole mux in the middleware once, and every route inherits it. For multiple middlewares, you chain them: logging(auth(rateLimit(mux))). It's less ergonomic than a fluent builder, but it works and doesn't need a library.
If you want per-route middleware, you wrap individual handlers when registering them. A small helper makes this readable.
The pattern scales to as many middlewares as you want: requireAdmin(auditLog(rateLimit(deleteProduct))). Each one is an http.HandlerFunc that wraps the next. The drawback is verbosity at the registration site; if every route needs the same five middlewares, you're repeating yourself five times per route. That's exactly the pain point that chi.Use was built to solve.
10 quizzes