Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Tags:
  • go
  • net-http
  • servemux
  • routing
  • pathvalue
  • debugging

Go's ServeMux Method Patterns: Why You Get 405, Not 404

Start with a small surprise. You register a route, hit it with the wrong verb, and expect the mux to shrug and say "not found". Instead:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "item %s\n", r.PathValue("id"))
	})

	log.Fatal(http.ListenAndServe(":8080", mux))
}
$ curl -i -X POST localhost:8080/items/42
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD

Method Not Allowed

$ curl -i localhost:8080/items
HTTP/1.1 404 Not Found

404 page not found

Both requests are "wrong" in some sense. The difference is whether any registered pattern matches the path at all. That is the whole rule, and it has a few consequences worth knowing about.

What the mux does

Go 1.22 rewrote http.ServeMux so a pattern can carry a method and wildcards. As I read the net/http docs, the outcome for a request goes like this:

  1. If a pattern matches both method and path, the most specific one wins and its handler runs.
  2. Otherwise, if a pattern matches the path but only for other methods, the mux answers 405 and sets an Allow header listing them.
  3. Otherwise, 404.

The 405 is the HTTP-correct answer (RFC 9110 says a 405 must carry an Allow header), and it is also more useful when you are debugging: "the route exists, you used the wrong verb" is a different bug from "you typed the URL wrong". Before 1.22 you had to check r.Method by hand inside the handler, and most people forgot.

Where PathValue fits in

r.PathValue("id") returns whatever the matched pattern captured for the wildcard, or an empty string if the name is not in the pattern. Note the consequence: on a 405 or 404 your handler never runs, so there is nothing to call it from. The values are filled in by the mux only after it has chosen a handler. If you wrap the mux in middleware and call r.PathValue before mux.ServeHTTP, you will get an empty string every time, because nothing has matched yet. That one catches people who try to log the id in an outer logging layer.

Two other things about wildcards. {id} matches exactly one whole path segment, so /items/a{id} is not valid. And {rest...} at the end matches the remainder of the path, slashes included. For a pattern ending in a slash that should match only that exact path, use {$}:

mux.HandleFunc("GET /{$}", home)          // only "/"
mux.HandleFunc("GET /files/{path...}", serve) // /files/a/b/c

The method-less pattern swallows the 405

Here is the bit that actually bites. Register this as well:

mux.HandleFunc("/items/{id}", fallback)

A pattern with no method matches every method. Now POST /items/42 finds a matching pattern, fallback, and the mux never gets as far as step 2. No 405, no Allow header. The more specific GET /items/{id} still wins for GET, and HEAD too, since a GET pattern also matches HEAD. If you want 405s, do not leave method-less patterns on the same paths. Alternatively, keep the catch-all and write the 405 yourself in it.

Method matching is also case sensitive, per the docs: "get /items" is not what you want, and I would expect the mux to reject it at registration. Registration is where conflicts show up generally. Two patterns that overlap without one being strictly more specific make Handle panic, for example GET /items/{id} and GET /{kind}/42. It fails at startup, which is the right time to find out.

Allow lists every method that matches

The Allow header is built from all patterns whose path matches, so it grows as you add routes:

mux.HandleFunc("GET /items/{id}", get)
mux.HandleFunc("DELETE /items/{id}", del)
// PUT /items/42 -> 405, Allow: DELETE, GET, HEAD

The exact ordering is the mux's business, so do not write a test that compares the header as a raw string unless you are happy to update it on a Go upgrade. Split on commas and compare as a set instead.

Testing it without a server

Since the whole behaviour lives in the mux, httptest is enough:

func TestRouting(t *testing.T) {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, r.PathValue("id"))
	})

	cases := []struct {
		method, path string
		want         int
	}{
		{"GET", "/items/42", http.StatusOK},
		{"HEAD", "/items/42", http.StatusOK},
		{"POST", "/items/42", http.StatusMethodNotAllowed},
		{"GET", "/items", http.StatusNotFound},
		{"GET", "/items/42/extra", http.StatusNotFound},
	}
	for _, c := range cases {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
		if rec.Code != c.want {
			t.Errorf("%s %s: got %d, want %d", c.method, c.path, rec.Code, c.want)
		}
	}
}

If you test a handler directly rather than through the mux, there is no matching step, so PathValue will be empty. Call req.SetPathValue("id", "42") on the request first; that method exists for exactly this.

A custom 404 that breaks 405

A common instinct is to make a nice JSON 404 by registering "/" as a catch-all. That is a method-less pattern matching everything, so it turns every wrong-method request into whatever your catch-all returns, usually a 404. The 405 disappears and clients get told a real route does not exist. If you want a custom not-found body, either register "GET /{$}" style patterns deliberately, or wrap the mux and rewrite the body of the response when the status is 404. Rewriting the response is a bit ugly, but it keeps the mux's own 405 logic intact.

One last check for older code: the new matching only applies when the module's go directive is 1.22 or later. If a go.mod says an earlier version, the old pattern behaviour is retained via GODEBUG (httpmuxgo121), and "GET /items/{id}" is treated as a literal path, so you get a plain 404 and no explanation. Check go.mod before blaming the router.