Why is r.URL.Path a poor metric label in a Go HTTP service, and what does http.Request.Pattern give you instead?
answer
- who decides the set of values?
- the path is filled in; the route is not
- one counter per id, or per route
- the mux already knows what matched
- r.Pattern versus r.URL.Path
basics
~20 sr.URL.Path holds the concrete requested path, so /items/1 and /items/2 become separate label values and the number of counters grows with traffic. http.Request.Pattern holds the ServeMux pattern that matched, a fixed string that changes only when you add a route.
solid answer
~40 sA metric label's value set should be decided by your code, not by callers. `r.URL.Path` is the concrete path off the request line, so a route like `/items/{id}` produces one distinct label value per id, and the process ends up holding one counter per id it has ever seen — the series count grows with traffic instead of with the number of routes. Since Go 1.23, `r.Pattern` carries the ServeMux pattern that matched the request, for example `"GET /items/{id}"`. That string is one of the handful you registered at startup, so labelling with it bounds the counter map by the size of your routing table. The id is still available to the handler through `r.PathValue("id")` — use it for the response and for logs, never as a label value.
code
go · 9 linesmux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
// For GET /items/42:
// r.URL.Path is "/items/42" -- a new label value for every id
// r.PathValue("id") is "42" -- use it here, never as a label
// r.Pattern is "GET /items/{id}" -- bounded by your routing table
countRoute(r.Pattern)
fmt.Fprintln(w, r.PathValue("id"))
})go deeper
Recall the three values for one request: r.URL.Path is the concrete path, r.PathValue("id") is the matched segment, r.Pattern is the registered route. Be ready to say which one you would attach to a counter and why.
Explain the mechanics: the mux records the matched pattern on the request, so the label's value set equals the routing table, while a path-derived label's value set equals whatever callers send. Know that r.Pattern arrived in Go 1.23 and the patterns themselves in Go 1.22.
Show the production instinct: name the 404 path as the leak everyone forgets, and say what you emit for unmatched requests. An interviewer expects you to connect label choice to a heap that grows with traffic rather than with deployments.
Own the rule rather than the instance. Decide what dimensions any service in the estate is allowed to emit, where high-cardinality context belongs instead, and how a shared helper makes the safe choice the default so no team has to remember it.
## What a label is, and why its value set matters An in-process metric is rarely one number. A request counter is normally a family of counters keyed by a few dimensions — labels — such as the route, the HTTP method and the status class. Every distinct combination of label values is its own counter, its own map entry and its own key string on the heap, and it must live for as long as the process does, because a counter that disappears and comes back looks like a reset. So the only interesting property of a label is: **who decides how many distinct values it can take?** If the answer is "the code", the counter map's size is a property of your program. If the answer is "whoever sends requests", the map's size is a property of your traffic, and it has no upper bound. ## r.URL.Path is decided by the caller `r.URL.Path` is the path from the request line, after URL decoding. For a service that serves `/items/{id}`, every id anyone ever requests is a different `r.URL.Path`. A million distinct ids means a million label values, a million map entries and a million retained strings — and that is before anyone scans you with random URLs, which is the fastest way to grow the map because the paths are entirely attacker-chosen. The same applies to anything else lifted straight out of the request: the query string (`r.URL.RawQuery`), a user or tenant id, a session token, a `User-Agent`. These identify the request; a label is supposed to identify the *kind* of request. ## What ServeMux gives you instead Go 1.22 added method and wildcard patterns to `http.ServeMux`: ```go mux.HandleFunc("GET /items/{id}", getItem) ``` Inside the handler, `r.PathValue("id")` returns the segment that the `{id}` wildcard matched — `"42"` for `/items/42`. That is exactly the value you need for your business logic and exactly the value you must not put in a label. Go 1.23 then added the exported field `Request.Pattern`. When `http.ServeMux` routes a request, it records the pattern it matched on the request, so the handler can read `r.Pattern` and get back the registered string, `"GET /items/{id}"` — the template, not the filled-in copy. Its value set is the set of patterns you registered, which changes when someone edits the routing table and at no other time. That is the whole trick of this topic: **the pattern is the label; the path value is the payload.** ## The three values side by side For a request `GET /items/42` handled by a handler registered as `"GET /items/{id}"`: | expression | value | safe as a label? | |---|---|---| | `r.URL.Path` | `/items/42` | no — one value per id | | `r.PathValue("id")` | `42` | no — one value per id | | `r.Pattern` | `GET /items/{id}` | yes — one value per route | ## Practical rules - Label the request counter with `r.Pattern`, the method (already inside the pattern if you registered it with one) and a coarse status, such as the status class rather than the exact code. - Read `r.PathValue("id")` for the work the handler has to do, and put it in a log line if you need it for debugging. A log line is a per-event record and can carry high-cardinality fields; a counter label cannot. - `r.Pattern` is empty when nothing matched — a 404 — so decide deliberately what you emit for those requests. Falling back to `r.URL.Path` there quietly reintroduces the unbounded label you just removed, because unmatched paths are the ones outsiders control completely. - Do not build the label by hand from string surgery on the path ("take the first two segments"). That is a second, undocumented routing table that drifts away from the real one; the mux already knows which pattern matched, so ask it. ## Why juniors get bitten by this The damage is invisible in development. With one developer clicking through a handful of ids, a path-keyed counter map has five entries and looks perfect. In production the same code accumulates entries for the entire id space, and the first symptom is not a broken dashboard — it is a process whose live heap keeps climbing for reasons that have nothing to do with request concurrency. That is why the fix belongs at the moment the label is chosen, not later.
- Where should the id go, if not on the counter's label?On the log record for that request, or on a trace span if you emit one. Those are per-event records: one more field costs one more field on one event. A counter label costs a permanent counter for every distinct value, so the two have completely different economics even though both are "attaching context".
- What does r.Pattern hold when the request matched no registered pattern?The empty string. `http.ServeMux` only sets it when a pattern matched, so 404s and the mux's own redirect responses come through with `r.Pattern == ""`. Emit a single fixed value such as `"other"` for those; falling back to `r.URL.Path` puts the unbounded label straight back in, on the one code path outsiders fully control.
- Is it enough to label with the method and the status, and drop the route entirely?It is bounded, but it throws away the dimension people actually need: a p99 or an error rate for the whole service tells you something is wrong, not where. The route pattern is the cheapest useful dimension precisely because its value set is your routing table, so keep it and drop the per-request identifiers instead.
A route pattern is a blank form; a request path is one filled-in copy of it. You want one folder per form, not one folder per copy.
saying these in an interview costs you the question
- Says r.URL.Path is fine because paths repeat in practice
- Puts r.PathValue("id") on the label to allow per-item breakdowns
- Reconstructs the route by splitting the path on slashes
- Thinks each label value is free because a counter is just an int64
- Falls back to the raw path whenever r.Pattern is empty