r.PathValue Returns Empty String in Go: Why the Wildcard Never Gets Set
Go · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: You switched to Go 1.22's method-and-wildcard routes, but r.PathValue("id") returns "" or every route 404s. Here are the three causes and their fixes.
What Changed
If r.PathValue returns an empty string in Go, the request almost certainly was not matched by the new pattern-aware net/http.ServeMux. Go 1.22 (February 2024) taught the standard library mux three things it never had:
- HTTP methods in patterns:
"GET /items/{id}" - Named wildcards:
{id}, plus{rest...}for the remainder of the path - Exact-match anchors:
{$}
It also added two request methods:
Request.PathValue(name)reads a wildcard value.Request.SetPathValue(name, value)lets tests and third-party routers inject one.
The catch: PathValue is just a lookup into a slot that ServeMux fills in during matching. Anything that skips the new matcher leaves the slot empty, and PathValue silently returns "". There is no error. The usual culprits are an old go directive, a direct handler call in a test, or an older third-party router.
The Old Way vs The New Way
Before 1.22 you either pulled in a router or hand-parsed the path:
mux.HandleFunc("/items/", 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, "/items/")
if id == "" || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
getItem(w, r, id)
})
With 1.22+ the standard library does it:
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
getItem(w, r, r.PathValue("id"))
})
mux.HandleFunc("POST /items", createItem)
mux.HandleFunc("GET /files/{path...}", serveFile) // remainder, may contain slashes
mux.HandleFunc("GET /{$}", home) // only "/", not a catch-all
With the new mux, a wrong method gets an automatic 405 with an Allow header, and GET patterns also answer HEAD.
Why It Was Added
Hand-rolled routing on the old mux produced a predictable class of bugs:
- Missing method checks, so a
DELETEhit a read handler. - Prefix patterns over-matching.
/items/happily served/items/42/whatever. /as an accidental catch-all. Unknown URLs returned your homepage with a 200.- Inconsistent 404 vs 405 behaviour across handlers.
The workaround was to adopt gorilla/mux or chi purely for {id} extraction. That adds a dependency, and its context-based param API (mux.Vars(r), chi.URLParam(r, ...)) leaks into every handler. The new mux covers the common 80% with zero dependencies and a stable accessor.
How It Works Underneath
Three steps matter.
1. Registration. When you call Handle, the mux checks the httpmuxgo121 GODEBUG setting.
- That setting defaults to
1(legacy behaviour) when your main module'sgodirective is below 1.22. - In legacy mode, a pattern not starting with
/is parsed as host plus path."GET /items/{id}"therefore becomes hostGETand literal path/items/{id}, and it never matches anything. - In new mode, the pattern is parsed into method, host and segments, and checked for conflicts.
- Two patterns that overlap without either being more specific cause a panic at registration, for example
/a/{x}and/{y}/b. The panic message says they "both match some paths" and that "neither is more specific".
2. Matching. On each request the mux picks the most specific matching pattern. Specificity rules:
- A literal segment beats a wildcard.
- A method-qualified pattern beats an unqualified one.
- Precedence does not depend on registration order.
The mux then stores the matched pattern and the wildcard values on the *Request. Since 1.23 the pattern is exposed as r.Pattern.
3. Lookup. PathValue("id") looks the name up in what step 2 stored. If step 2 never ran, or ran on a different *Request value, you get "". This happens with handlers called directly, routers that do not call SetPathValue, and middleware that builds a fresh request instead of using r.WithContext.
flowchart TD
A["mux.HandleFunc('GET /items/{id}', h)"] --> B{httpmuxgo121 setting<br/>from go.mod go directive}
B -- "=1 (go < 1.22)" --> C["Legacy parse:<br/>host='GET ', path literal '/items/{id}'"]
C --> D["Request /items/42<br/>never matches -> 404"]
B -- "=0 (go >= 1.22)" --> E["Parse method + segments<br/>conflict check, may panic"]
E --> F["Request GET /items/42<br/>most-specific match"]
F --> G["Store pattern + {id:'42'}<br/>on *http.Request"]
G --> H["h(w, r): r.PathValue('id') == '42'"]
T["Test calls h(rec, req) directly"] --> U["Match step skipped<br/>slot empty"]
U --> V["r.PathValue('id') == ''"]
T -. "req.SetPathValue('id','42')" .-> H
Should You Adopt It Yet
Yes, for most services. It has shipped in every Go release since 1.22, and the conflict detection catches routing ambiguities at startup instead of in production.
Costs to weigh:
- Bumping the
godirective to 1.22 also changesforloop variable semantics to one variable per iteration. That is usually a bug fix, but run your tests. - The new mux has no regex constraints and no built-in route groups or middleware chaining. If you depend on those, stay on chi.
- chi is still a fine choice. Recent chi v5 versions call
SetPathValue, sor.PathValueworks there too. Verify against your version.
Who should wait:
- Libraries that must support Go versions below 1.22.
- Services with large regex-based route tables.
Migration Notes
- Check
go.modfirst. Thegoline, not your installed toolchain, decides the mux behaviour. Ago.workfile can make things work locally and fail in CI. Opt in withgo 1.22or later, or temporarily withgodebug httpmuxgo121=0(go.mod, 1.23+) or//go:debug httpmuxgo121=0. - Grep for what to rewrite:
strings.TrimPrefix(r.URL.Pathr.Method !=mux.Vars(andchi.URLParam(HandleFunc("/",
- Fix catch-alls. Change a bare
"/"to"GET /{$}"if you only meant the root path. - Fix unit tests that call handlers directly:
req := httptest.NewRequest("GET", "/items/42", nil)
req.SetPathValue("id", "42") // or: mux.ServeHTTP(rec, req)
h(rec, req)
- Watch for panics after reorganising routes. A startup panic that says two patterns "conflict" means they overlap ambiguously. Make one strictly more specific instead of reordering registrations, because order no longer matters.
- Add a smoke test. Assert a wildcard route returns 200 through the real mux, so a stale
godirective can never silently 404 every route again.
Key takeaway: PathValue is only populated when the new ServeMux matches the request, so check your go.mod go directive first and use req.SetPathValue in unit tests that call handlers directly.
Real-world challenge
A team upgrades their CI and Docker images to Go 1.23 and rewrites routes as mux.HandleFunc("GET /users/{id}", getUser). Everything compiles. After deploy, every rewritten route returns 404, and the old routes like "/healthz" still work. Locally, one developer's checkout works fine. Their go.mod still says `go 1.21`. The developer whose checkout works has a go.work file pinned to go 1.23.
Diagnosis
The new routing syntax is gated by the httpmuxgo121 GODEBUG setting. Its default comes from the go directive of the main module (or the workspace), not from the toolchain version.
- With
go 1.21in go.mod, the binary defaults tohttpmuxgo121=1, which means the legacy mux. - The legacy mux treats a pattern that does not start with
/as host-qualified. So"GET /users/{id}"is read as host"GET "plus the literal path/users/{id}. - No request ever matches that, so every rewritten route returns 404.
/healthzstill works because it is a valid pattern under the old rules.- The working laptop gets the new default from its go.work file.
Fix
Bump the language version in go.mod. This is the real fix, and it also enables 1.22 loop-variable semantics, so run your tests:
// go.mod
module example.com/api
go 1.22
If you cannot bump yet, opt in explicitly:
// go.mod (Go 1.23+ toolchain)
godebug httpmuxgo121=0
Or add //go:debug httpmuxgo121=0 above package main.
Prevent it
- Add a startup smoke test that sends a request to a wildcard route through the real mux and asserts a 200 response.
- Make sure CI builds without the go.work file, the same way production does.