skip to content

A middleware wrapping http.ServeMux reads r.Pattern before calling next.ServeHTTP and always gets an empty string. Why?

level: middleimportance: should knowfreq 40%

answer

  1. who fills the field, and when
  2. nothing has routed yet
  3. the read is above the delegation
  4. move it below next.ServeHTTP
  5. or wrap the handler, not the mux

basics

~10 s

Nothing has routed the request yet. http.ServeMux fills in the Pattern field while it matches the request inside its own ServeHTTP, so an outer middleware sees an empty string until that call returns.

solid answer

~40 s

`Pattern` is not set by the server when the request arrives; it is set by `http.ServeMux` at the moment it decides which registered pattern matches, which happens inside the mux's `ServeHTTP`. A middleware that wraps the mux runs its pre-call code before any routing has happened, so `r.Pattern` is still the zero value there. Two fixes. Read the field after `next.ServeHTTP(w, r)` returns — the mux fills it in on the very request value you passed down, so your variable sees it. Or move the middleware inside the mux by wrapping the handler you register under the pattern, in which case the mux has already routed before your code runs and `r.Pattern` is populated on entry. Watch out for a middleware that clones the request first: read from the copy it actually handed downstream.

code

go · 8 lines
go
func withRouteLabel(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// r.Pattern is still "" here: the mux has not matched yet.
		next.ServeHTTP(w, r)
		// ServeMux recorded the matched pattern on this same request.
		countRoute(r.Pattern)
	})
}

go deeper

for a junior

Remember the order of events: the server calls your outermost handler first, the mux matches a pattern second, the route handler runs third. Anything the mux records is invisible to code that runs before you delegate.

for a middle

Be ready to explain that ServeMux records the matched pattern on the request it routes, and to give both fixes: read after next.ServeHTTP returns, or register the wrapper under the pattern. Mention the cloned-request trap.

for a senior

Show that you treat the empty pattern as a designed case, not a bug — unmatched traffic exists and must land in one fixed bucket. Be able to say how you would notice the whole service reporting a blank route label in the first place.

for a principal

Decide where instrumentation attaches across the estate: one outer wrapper that every service gets for free, or per-route registration that costs discipline but yields the legal label set at startup. Own that choice and the migration if you change it.

## Who sets the field, and when `http.Request.Pattern` is not part of the wire format and is not filled in by the HTTP server when it parses a request. It is filled in by the router. Concretely, `http.ServeMux.ServeHTTP` looks up the request, and as part of that lookup it records on the request both the pattern string that matched and the wildcard values that `r.PathValue` will later return. Only then does it call the matched handler. That ordering is the whole answer to the question. There are three points in the lifetime of one request: 1. The server hands the request to `Server.Handler` — `Pattern` is `""`. 2. `ServeMux.ServeHTTP` matches a registered pattern and records it — `Pattern` is now set. 3. The matched handler runs — `Pattern` is set, `r.PathValue(name)` works. A middleware chain that looks like `logging(metrics(mux))` runs entirely at step 1 for everything it does *before* delegating. So this is empty every time: ```go func bad(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { route := r.Pattern // always "": nothing has routed yet next.ServeHTTP(w, r) countRoute(route) }) } ``` ## Fix one: read it afterwards The mux records the pattern on the request value it is routing, which is the same `*http.Request` your middleware passed into `next.ServeHTTP`. So moving the read below the delegation is enough: ```go next.ServeHTTP(w, r) countRoute(r.Pattern) // set by the mux during the call above ``` This is usually what you want anyway, because everything else a metrics middleware records — the status, the elapsed time, whether the handler panicked — is only known after the inner call returns. Reading the route in the same place keeps all the label sources together. One caveat. If your middleware substitutes a different request before delegating, for example `r = r.WithContext(ctx)` or `r.Clone(ctx)`, then the mux fills in the *substitute*. If you reassigned the same variable you are fine; if you kept the original around and read from that one, you will still see an empty string. Read from whatever you actually handed downstream. ## Fix two: run inside the mux The other option is to stop wrapping the mux and start wrapping the handler you register: ```go mux.Handle("GET /items/{id}", withRouteLabel(getItem)) ``` Now the mux routes first and calls your wrapper second, so `r.Pattern` is already populated on entry and `r.PathValue("id")` works too. The cost is that you must remember the wrapper on every route; the benefit is that per-route middleware can also do per-route things, and you can record the pattern at registration time as a closed-over constant rather than reading it from the request at all. ## Which requests never get a pattern Even with the read in the right place, `r.Pattern` can legitimately be empty: - The request matched no registered pattern, so the mux served its not-found handler. This is the common one, and the requests that land there are the ones outsiders choose freely. - The mux answered with its own redirect for a trailing-slash or path-cleaning case rather than dispatching to a registered handler. - The handler was never reached through a pattern-matching `ServeMux` at all — for example a bare `http.HandlerFunc` installed directly as `Server.Handler`. So an outer middleware must treat `""` as a real, expected case and map it to one fixed label value, not to the raw path. ## Why this matters more than it looks The failure is silent and self-hiding. The counter still increments, the label is still present, and every request just lands in the same empty-string bucket — a dashboard that looks alive and answers no question. Worse, the usual reflex on discovering the empty label is to "fall back to something useful", and the most obvious something is `r.URL.Path`, which reintroduces an unbounded label set on the path outsiders control. Getting the ordering right the first time removes the temptation.

  • Your middleware does r2 := r.Clone(ctx) and passes r2 down, then reads r.Pattern afterwards. What happens?
    It stays empty. The mux records the matched pattern on the request it was given, which is `r2`, and `Clone` produced an independent copy — filling in one does not touch the other. Read `r2.Pattern` after the call, or reassign `r = r.WithContext(ctx)` so the variable you read from is the one you passed down.
  • How would you record the route label without reading r.Pattern at all?
    Wrap at registration: a helper that takes the pattern string and the handler can close over that string and hand it to the counter directly, because the pattern is a compile-time constant of the route. That also gives you the list of legal label values at startup, which is exactly the allowlist you want for unmatched requests later.
  • Should the middleware skip counting when r.Pattern is empty?
    No. Unmatched traffic is real traffic and a sudden spike of it is a signal worth having — a scanner, a broken client, a route that was deleted. Count it under one fixed bucket such as `"other"` so it stays visible without adding a label value per URL anyone invents.

saying these in an interview costs you the question

  • Thinks the HTTP server populates Pattern when it parses the request
  • Concludes the field is broken or was removed
  • Falls back to r.URL.Path the moment Pattern is empty
  • Reads Pattern from the original request after passing a clone down
  • Assumes every request that reaches the middleware matched a pattern