Skip to content
gohacks.dev
Web

Path Parameters in Go Without a Third Party Router

Read and validate path parameters with the standard library in Go: PathValue, integers, UUIDs and slugs, catch-all paths and testing handlers.

8 min read
Summarize in AI
Flat illustration of a request passing through concentric middleware layers to a handler.

Path parameters are the part of a URL that names a thing: /users/42, /orders/2024-0017/items/3. Before Go 1.22, reading them with the standard library meant splitting r.URL.Path by hand, which is where off-by-one errors and missing checks live. Now the router does the matching and the request does the lookup, and what is left is the part the standard library deliberately does not do: turning a string from the network into a value you can trust.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article covers reading the parameter, parsing it into integers, UUIDs and slugs, the choice between 400 and 404, catch-all paths and the security trap in them, encoding quirks, and how to test handlers that read path values. It builds on the router patterns, pairs with query parameters, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6.

Reading a value

Go
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id") // "42" for /users/42
	// ...
})

PathValue takes the name of a wildcard in the pattern. The result is a string, percent-decoded, containing no slashes for a {name} wildcard. If the name does not appear in the pattern, you get the empty string; there is no error, so a typo in the name ("ID" against {id}) is silent. Names are case-sensitive. A test that exercises the real route catches this class of bug, and a constant for each name used in both the pattern and the lookup prevents it.

Parse, then decide the status

The router guarantees a segment is present, not that it is meaningful. Handlers need a small parse step, and the helper below is the shape I use: it returns the value or writes the error response and reports whether to continue.

params.goGo
package main

import (
	"net/http"
	"regexp"
	"strconv"
)

// pathInt reads a positive integer path parameter. On failure it writes a 400 and returns false.
func pathInt(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
	n, err := strconv.Atoi(r.PathValue(name))
	if err != nil || n < 1 {
		http.Error(w, "invalid "+name, http.StatusBadRequest)
		return 0, false
	}
	return n, true
}

var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
var slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

// pathUUID reads a UUID path parameter.
func pathUUID(w http.ResponseWriter, r *http.Request, name string) (string, bool) {
	v := r.PathValue(name)
	if !uuidRe.MatchString(v) {
		http.Error(w, "invalid "+name, http.StatusBadRequest)
		return "", false
	}
	return v, true
}

// pathSlug reads a lowercase slug such as "go-modules" and bounds its length.
func pathSlug(w http.ResponseWriter, r *http.Request, name string) (string, bool) {
	v := r.PathValue(name)
	if len(v) > 100 || !slugRe.MatchString(v) {
		http.Error(w, "invalid "+name, http.StatusBadRequest)
		return "", false
	}
	return v, true
}

strconv.Atoi accepts a leading + or -, so the n < 1 check matters, and it also rejects the wrap-around of huge numbers by returning an error for values outside int. For ids that are 64-bit, use strconv.ParseInt(v, 10, 64) or ParseUint. If your ids are UUIDs, the regular expression above checks the shape; a UUID library does the same with proper types, and the UUID generator and decoder tools on this site are handy when you need to inspect one. Slugs are a good example of the principle: the allowlist [a-z0-9-] and a length cap say what is accepted, which is much safer than trying to say what to reject.

Wire them into a small API and exercise it with good and bad input:

main.goGo
package main

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

func main() {
	users := map[int]string{1: "ada", 2: "grace"}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
		id, ok := pathInt(w, r, "id")
		if !ok {
			return
		}
		name, found := users[id]
		if !found {
			http.Error(w, "no such user", http.StatusNotFound) // well-formed, but absent
			return
		}
		fmt.Fprintln(w, name)
	})
	mux.HandleFunc("GET /posts/{slug}", func(w http.ResponseWriter, r *http.Request) {
		slug, ok := pathSlug(w, r, "slug")
		if !ok {
			return
		}
		fmt.Fprintln(w, "post:", slug)
	})

	for _, path := range []string{"/users/1", "/users/99", "/users/abc", "/users/-5", "/users/0", "/users/99999999999999999999",
		"/posts/go-modules", "/posts/Go_Modules", "/posts/" + "a-very-long-slug"} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		fmt.Printf("%-34s -> %d %q\n", path, rec.Code, rec.Body.String())
	}
}
Text
/users/1                           -> 200 "ada\n"
/users/99                          -> 404 "no such user\n"
/users/abc                         -> 400 "invalid id\n"
/users/-5                          -> 400 "invalid id\n"
/users/0                           -> 400 "invalid id\n"
/users/99999999999999999999        -> 400 "invalid id\n"
/posts/go-modules                  -> 200 "post: go-modules\n"
/posts/Go_Modules                  -> 400 "invalid slug\n"
/posts/a-very-long-slug            -> 200 "post: a-very-long-slug\n"

The statuses tell the story: /users/1 is 200, /users/99 is 404 because 99 is a valid id with no record, and abc, -5, 0 and the oversized number are all 400 because they are not valid ids at all. That split is useful to clients. A 404 says "this resource does not exist" and a 400 says "your request is malformed", so a client bug stands out from a deleted record. There is one more judgment call: some APIs deliberately return 404 for malformed ids too, to avoid telling a scanner anything. Pick one convention and apply it everywhere.

Catch-all segments and the filesystem

{rest...} captures the remainder of the path, slashes included, and is how you serve something shaped like a file tree. It is also where path traversal bugs begin, because a value that looks like a/b/c.txt is tempting to join onto a directory. The mux helps by cleaning the path: a request containing .. segments gets a redirect to the cleaned path before routing. This program shows what a handler actually receives:

main.goGo
package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /files/{rest...}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "rest=%q", r.PathValue("rest"))
	})
	for _, target := range []string{"/files/a/b.txt", "/files/../secret", "/files/a/../../secret", "/files/%2e%2e/secret", "/files/a%2fb"} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("GET", target, nil))
		result := rec.Body.String()
		if rec.Code == http.StatusMovedPermanently {
			result = "redirect to " + rec.Header().Get("Location")
		}
		fmt.Printf("%-26s -> %d %s\n", target, rec.Code, result)
	}
}
Text
/files/a/b.txt             -> 200 rest="a/b.txt"
/files/../secret           -> 301 redirect to /secret
/files/a/../../secret      -> 301 redirect to /secret
/files/%2e%2e/secret       -> 200 rest="../secret"
/files/a%2fb               -> 200 rest="a/b"

Plain .. traversal is turned into a 301 to the cleaned path, so the handler never sees it. The encoded forms are the surprise. /files/%2e%2e/secret was not redirected: the handler ran with status 200 and received rest="../secret", because the cleaning looks at the raw path, where %2e%2e is not yet a dot segment, and the wildcard value is decoded afterwards. The encoded slash behaves similarly: %2f stays inside the value, so /files/a%2fb bound rest to a/b. The mux's cleaning is a convenience, not a guarantee you can depend on for security. If you map the value to a file, use http.FS or os.Root (Go 1.24), which confine paths to a directory, and never filepath.Join(base, rest) without checking the result stays inside base (serving files without leaks).

Encoded characters

Wildcards are matched on the escaped path and the value is returned unescaped. So /users/a%20b binds id to a b, and a literal %2F yields a slash inside the value. This is usually what you want, and occasionally surprising when you build URLs: when a segment can contain arbitrary text, escape it with url.PathEscape on the way out so that slashes and question marks inside it do not change the meaning of the URL. If you need the raw, still-escaped form, r.URL.EscapedPath() has it.

Testing handlers

Two styles, depending on what you want to cover. Test through the router (mux.ServeHTTP with a real path) when routing is part of the behavior, as above. Test the handler alone by setting the value yourself:

Go
req := httptest.NewRequest("GET", "/users/42", nil)
req.SetPathValue("id", "42") // as if the router had matched {id}
rec := httptest.NewRecorder()
srv.handleUser(rec, req)

SetPathValue exists for exactly this. The handler-only test is shorter and isolates the logic; the through-the-router test also catches a pattern whose wildcard name does not match what the handler reads. I write one of each for any handler with parameters, and a table-driven test over the bad inputs listed above.

Design notes

Prefer opaque ids to sequential integers in public URLs. /users/1, /users/2 invites enumeration; if the data is not public, authorize every access regardless of how the id looks, because ids are not secrets.

Keep parameter handling in one place. Helpers like pathInt keep the error responses consistent. Return structured errors if the API is JSON (writing JSON responses).

Do not log raw path values at error level without limits. A scanner can send megabytes of garbage in a path; cap what you log and what you accept. The server's MaxHeaderBytes limits the request line already, 1 MiB by default.

Do not parse by hand what the router already parsed. strings.Split(r.URL.Path, "/") in a handler is a sign that the pattern could have a wildcard.

The reference for what the matcher does is the ServeMux documentation, and RFC 3986 section 3.3 defines path segments and percent-encoding (URI syntax). Query strings and form values, the other half of reading input, are in the next article.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.