Path and Query Parameters

Read variable path segments with r.PathValue and the query string with r.URL.Query, then validate every value before using it.

Syntaxr.PathValue(name string) string // r.URL.Query().Get(key string) string

Most URLs carry data. It sits in two places. The path can contain a variable segment, such as the 7 in /users/7. The query string is the part after ?, such as ?limit=20&sort=name. Both are chosen by whoever sent the request.

Reading them

A wildcard from the route pattern is read with r.PathValue("id"). It returns a string, and an empty string if no wildcard of that name is in the pattern. The query string is read with r.URL.Query(), which returns a url.Values. That is a map from a name to a list of strings, because the same name may appear more than once. Get("limit") returns the first value, or an empty string when the name is absent. Has("limit"), added in Go 1.17, reports whether the name was present at all, which distinguishes ?limit= from no limit at all.

Both are untrusted

Every value arrives as a string typed by a stranger. Convert it yourself: strconv.Atoi for a whole number, and check its error. Then check the range, because limit=1000000 parses perfectly and will still hurt the database. On a bad value reply with http.Error(w, "limit must be 1-100", http.StatusBadRequest) and return at once. A missing value is not an error if you have a default for it.

What trips people up

r.URL.Query() parses the query string on every call. Call it once, keep the url.Values, and read several names from that.

Example

Example · go
package main

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

func show(w http.ResponseWriter, r *http.Request) {
	// {id} comes from the route pattern below.
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil || id < 1 {
		http.Error(w, "id must be a positive number", http.StatusBadRequest)
		return
	}

	limit := 10 // used when ?limit= is absent
	if raw := r.URL.Query().Get("limit"); raw != "" {
		n, err := strconv.Atoi(raw)
		if err != nil || n < 1 || n > 100 {
			http.Error(w, "limit must be 1-100", http.StatusBadRequest)
			return
		}
		limit = n
	}
	fmt.Fprintf(w, "user %d, limit %d", id, limit)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", show)

	for _, target := range []string{"/users/7?limit=2", "/users/7", "/users/abc"} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("GET", target, nil))
		fmt.Printf("%d %s\n", rec.Code, strings.TrimSpace(rec.Body.String()))
	}
}

// Output:
// 200 user 7, limit 2
// 200 user 7, limit 10
// 400 id must be a positive number

When to use it

  • Fetch one record by the id in /users/{id}.
  • Apply optional ?limit= and ?offset= to a list endpoint with safe defaults.
  • Reject a malformed id with 400 instead of letting it reach the database.

More examples

A wildcard that swallows the rest of the path

The three dots make the wildcard match every remaining segment, slashes included, which is what you want for anything tree-shaped: a documentation path, an object key, a nested resource. Because the value can contain .., anything that turns it into a filename has to reject that first.

Example · go
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
	// For /files/reports/2026/q1.csv, path is "reports/2026/q1.csv".
	rel := r.PathValue("path")
	// A path segment may contain "..", which could escape the directory.
	if rel == "" || strings.Contains(rel, "..") {
		http.Error(w, "bad path", http.StatusBadRequest)
		return
	}
	fmt.Fprintln(w, rel)
})

A name that appears more than once

Get only ever returns the first value, so repeated names need the map directly. Indexing a url.Values with a name that is absent yields an empty slice rather than an error, so len is the check. Reach for Has when the presence of a flag is the message and its value is irrelevant.

Example · go
q := r.URL.Query() // parse once, read many times

// ?tag=go&tag=http gives []string{"go", "http"}
tags := q["tag"]
if len(tags) > 5 {
	http.Error(w, "at most 5 tags", http.StatusBadRequest)
	return
}

// Has distinguishes ?draft= (present, empty) from no draft at all.
includeDrafts := q.Has("draft")

One helper for every numeric parameter

Parsing and range-checking is the same six lines in every handler, and the copies drift apart. One helper gives every endpoint the same defaults, the same limits and the same wording in its error. This is the shape that list endpoints use for page and limit.

Example · go
// intParam reads name from q, with a default and a permitted range.
func intParam(q url.Values, name string, def, lo, hi int) (int, error) {
	raw := q.Get(name)
	if raw == "" {
		return def, nil
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n < lo || n > hi {
		return 0, fmt.Errorf("%s must be a whole number between %d and %d", name, lo, hi)
	}
	return n, nil
}

// In a handler:
//   limit, err := intParam(r.URL.Query(), "limit", 20, 1, 100)
//   if err != nil {
//       http.Error(w, err.Error(), http.StatusBadRequest)
//       return
//   }

Discussion

  • Be the first to comment on this lesson.