skip to content

After adding a route to a Go http.ServeMux, requests that used to reach one handler now reach another — how do you diagnose it?

level: seniorimportance: should knowfreq 38%

answer

  1. the overlap was legal, so nothing complained
  2. specificity, not the order of the lines
  3. the mux can be asked, not just run
  4. one call returns the matched pattern
  5. make it a table row per route

basics

~20 s

The new pattern is more specific, so it wins regardless of registration order, and that overlap is legal rather than an error. Ask the mux: mux.Handler(req) returns the matched pattern. Pin it with a table-driven test.

solid answer

~50 s

Start from the rule: `ServeMux` picks the pattern whose matched request set is a strict subset of the other's, so a newly added `"GET /items/new"` takes over a path that `"GET /items/{id}"` used to serve — and because one pattern genuinely is more specific, nothing panics and nothing is logged. To see it, rebuild the production mux in a test and call `mux.Handler(r)` with a request from `httptest.NewRequest`: it returns the handler and the **pattern string** that matched, which is the ground truth to assert. Write that as a table of method, path and expected pattern covering every route plus the edges — a wrong method, a trailing-slash variant, a path that should 404 — and require a row with every new route. Run the same table against the previous commit to size what moved.

code

go · 14 lines
go
func TestRouteTable(t *testing.T) {
	mux := newRouter() // the same constructor main uses
	cases := []struct{ method, path, want string }{
		{"GET", "/items/42", "GET /items/{id}"},
		{"GET", "/items/new", "GET /items/new"},
		{"GET", "/items/42/tags", "GET /items/{id}/tags"},
	}
	for _, tc := range cases {
		r := httptest.NewRequest(tc.method, tc.path, nil)
		if _, pattern := mux.Handler(r); pattern != tc.want {
			t.Errorf("%s %s matched %q, want %q", tc.method, tc.path, pattern, tc.want)
		}
	}
}

go deeper

for a junior

Know that adding a route can change which handler an existing path reaches, and that moving the registration line is not the fix.

for a middle

Explain why this is not reported as a conflict: one pattern is strictly more specific than the other, which is a legal overlap the mux resolves on purpose.

for a senior

Show the loop: rebuild the production mux in a test, ask it which pattern each path matches, compare against the previous release to size the blast radius, then keep the table as a guard.

for a principal

Make route coverage a standard across services so routing changes arrive as reviewable data, and so a new engineer can read the URL-to-handler map in one place.

## Why the failure is silent Go's `ServeMux` has two responses to overlapping patterns. If neither pattern's request set contains the other's, it **panics at registration** — loud, immediate, impossible to miss. If one pattern's set is a strict subset of the other's, the overlap is **legal and intended**, and the narrower pattern serves the requests they share. That second case is the whole point of being able to register `"/items/{id}"` and `"/items/new"` together, and it is also exactly the shape of this failure: someone added a route that is narrower than an existing one, and traffic that used to fall to the broad pattern now lands on the narrow one. There is no warning because there is nothing wrong from the mux's point of view. Two things make it hard to spot in review. First, the two registrations are frequently in different files or different functions, because Go's order-independence encourages splitting them up. Second, the reviewer's instinct from list-walking routers — scan for a catch-all placed too early — finds nothing, since order carries no meaning here. ## The diagnostic: ask the mux `ServeMux` will tell you what it would do. `mux.Handler(r)` takes a request and returns the handler it would dispatch to **and the pattern string that matched**. That second return value is the assertion you want: comparing handlers is awkward (function values are not comparable), while comparing pattern strings is exact, readable in a failure message, and stable across refactors of the handler itself. The method for a live incident: 1. Construct the mux exactly as production does. This is the argument for a `newRouter() *http.ServeMux` constructor rather than registrations scattered through `main`: the test can build the real thing instead of a lookalike. 2. Build requests with `httptest.NewRequest(method, path, nil)`. 3. For each, print or assert the pattern from `mux.Handler`. 4. Run the same table against the previous release's registrations. The rows whose pattern changed are your blast radius, and that list is what goes in the incident note. ## The prevention: a route table test Turn step 3 into a permanent table-driven test. Each row is a method, a request path and the pattern expected to serve it. This is cheap — no server, no network, microseconds per row — and it converts routing from prose in a pull-request description into data a reviewer can read. Useful rows beyond the happy path: - **The wildcard's neighbours.** If `"/items/{id}"` exists, assert a row for a realistic id *and* for every literal sibling you have deliberately carved out, so adding another literal without a row is visible. - **Wrong method.** Send a POST at a GET-only path and assert the status through `httptest.NewRecorder` and `mux.ServeHTTP`: you should see 405 and an `Allow` header, not 404 and not a 200. - **Trailing-slash variants.** Both `/items` and `/items/`, so a change from a bare pattern to a subtree pattern (which introduces a redirect) shows up as a changed expectation. - **A path that must not match anything**, to keep a broad pattern from quietly acquiring it later. One subtlety worth knowing while reading such a table: pattern paths and request paths are matched **segment by segment after unescaping**, so a request for `/items/a%2Fb` is a single segment whose value is `a/b`. A path that looks like two segments in the raw URL may be one for matching purposes, which is why the table should hold at least one escaped-segment row for wildcard routes. ## What not to do The two wrong reflexes are worth naming out loud, because an interviewer is listening for them. **Reordering the registrations** does nothing at all — `ServeMux` compares patterns, never positions. And **renaming or narrowing the new route until the symptom disappears** treats the wrong path as the requirement; decide deliberately which pattern *should* own `/items/new`, then make the table say so. ## Making it a habit On a service with more than a handful of routes, the route table test earns its keep as documentation as much as as a guard: it is the one place a new engineer can read, in one screen, which URL reaches which handler. Requiring a row with every route addition costs the author a line and makes the routing consequence of a pull request reviewable — which is the actual fix for a class of bug whose defining feature is that nobody sees it happen.

  • Why did the mux not report this overlap as a conflict at registration?
    A conflict is only raised when neither pattern matches a strict subset of the other. Here the new pattern is strictly narrower, which is a legal and deliberate overlap — the same mechanism that lets a literal route sit beside a wildcard route. Legality is the reason it is silent.
  • What would you assert in that test besides the matched pattern?
    Status codes for the edges: a wrong method should give 405 with an `Allow` header, a slash-less subtree root should give a redirect to the slashed form, and an unregistered path should give 404. Run those through `httptest.NewRecorder` and `mux.ServeHTTP`. For wildcard routes, also assert the value the handler reads back.
  • How would you catch the opposite problem — a registered route nothing can reach?
    In Go the narrower pattern always wins, so a route cannot be shadowed into unreachability the way it can in a first-match router; the risk is instead a route nobody exercises. Assert coverage in the other direction: every registered pattern must appear as the expected value of at least one row in the table.

saying these in an interview costs you the question

  • Blames registration order and moves the route earlier
  • Says a wildcard always beats a literal segment
  • Tests handler bodies but never which route matched
  • Assumes an overlapping pattern would have panicked at startup
  • Narrows the new route until the symptom disappears