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:

It also added two request methods:

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:

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.

2. Matching. On each request the mux picks the most specific matching pattern. Specificity rules:

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:

Who should wait:

Migration Notes

req := httptest.NewRequest("GET", "/items/42", nil)
req.SetPathValue("id", "42") // or: mux.ServeHTTP(rec, req)
h(rec, req)

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.

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