Skip to content
gohacks.dev
Web

Query Strings and Form Values in Go Requests Explained

Read query strings and form values in Go net/http: Query, FormValue and PostFormValue compared, repeated keys, defaults and bounds for pagination.

8 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Query strings carry the optional parts of a request: pagination, sorting, filters, a search term. Form bodies carry the data a browser posts from an HTML form. Both arrive in the same shape, a set of keys with string values, and both are untrusted input in the full sense: anyone can send anything. The standard library parses them for you and stops there. What remains is the part that decides whether an API is pleasant and safe: defaults, types, bounds and clear errors.

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

This article covers the three ways to read values, the differences that surprise people, a small set of typed helpers, and a pagination handler that uses them. It pairs with path parameters, leads into decoding JSON bodies, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6.

Three ways to read, and what each reads

r.URL.Query() parses the raw query string into a url.Values, which is a map[string][]string. It reads only the URL, never the body, which makes it the right tool for GET requests:

Go
q := r.URL.Query()
page := q.Get("page") // first value, or "" if absent
tags := q["tag"]      // every value of a repeated key
_, hasDebug := q["debug"]

r.FormValue("name") is the older convenience. It calls ParseMultipartForm for you, with a 32 MiB memory limit, and returns the first value from the combined set of the body (for application/x-www-form-urlencoded and multipart forms) and the URL query, with body values taking precedence. r.PostFormValue("name") reads only the body. r.Form and r.PostForm expose the parsed maps after ParseForm.

The difference shows up in a short program. A POST has x=from-body in the form body and x=from-query in the URL:

main.goGo
package main

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

func main() {
	body := url.Values{"x": {"from-body"}, "only_body": {"b"}}.Encode()
	req := httptest.NewRequest("POST", "/submit?x=from-query&only_query=q", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	fmt.Println("Query().Get(x):      ", req.URL.Query().Get("x"))
	fmt.Println("FormValue(x):        ", req.FormValue("x"))
	fmt.Println("PostFormValue(x):    ", req.PostFormValue("x"))
	fmt.Println("FormValue(only_query):", req.FormValue("only_query"))
	fmt.Println("PostFormValue(only_query) is empty:", req.PostFormValue("only_query") == "")
	fmt.Println("r.Form[x]:           ", req.Form["x"])
}
Text
Query().Get(x):       from-query
FormValue(x):         from-body
PostFormValue(x):     from-body
FormValue(only_query): q
PostFormValue(only_query) is empty: true
r.Form[x]:            [from-body from-query]

Query() sees only the URL. FormValue returned the body's from-body, ahead of the query's from-query, but r.Form["x"] holds both, body first. PostFormValue ignores the query. For a JSON API, none of the form methods apply: a Content-Type: application/json body is not form data and FormValue will not read it. The practical rule: use r.URL.Query() for query strings, use r.PostFormValue when you specifically want form fields from a POST, and avoid FormValue in new code because it blends two sources and a caller can put a value in the URL that you expected only in the body.

Absent, empty and repeated

Get returns "" both when the key is missing and when it is present with no value (?q= or ?q). If the difference matters, check with Has (Go 1.17) or look the key up in the map:

Go
q := r.URL.Query()
if !q.Has("filter") {
	// the parameter was not sent at all
} else if q.Get("filter") == "" {
	// sent, but empty: "?filter="
}

A key that appears more than once is a slice. ?tag=go&tag=http gives q["tag"] == []string{"go", "http"}, while Get returns only "go", silently dropping the rest. APIs that accept lists have two conventions: repeated keys (tag=a&tag=b) or a delimited value (tag=a,b). Pick one and document it; the delimited form needs splitting and trimming in your code, and repeated keys need nothing extra.

Typed helpers with defaults and bounds

Every optional parameter needs a default and every numeric one needs bounds. A per_page of 10 million is a denial-of-service request wearing a pagination parameter. Small helpers keep the handlers readable:

params.goGo
package main

import (
	"fmt"
	"net/url"
	"strconv"
	"strings"
)

// intParam returns the integer value of key, def if it is absent, or an error if it is malformed or out of range.
func intParam(q url.Values, key string, def, min, max int) (int, error) {
	raw := q.Get(key)
	if raw == "" {
		return def, nil
	}
	n, err := strconv.Atoi(raw)
	if err != nil {
		return 0, fmt.Errorf("%s must be an integer", key)
	}
	if n < min || n > max {
		return 0, fmt.Errorf("%s must be between %d and %d", key, min, max)
	}
	return n, nil
}

// boolParam accepts true/false, 1/0 and yes/no.
func boolParam(q url.Values, key string, def bool) (bool, error) {
	switch strings.ToLower(q.Get(key)) {
	case "":
		return def, nil
	case "true", "1", "yes":
		return true, nil
	case "false", "0", "no":
		return false, nil
	}
	return false, fmt.Errorf("%s must be true or false", key)
}

// enumParam accepts only values from an allowlist.
func enumParam(q url.Values, key, def string, allowed ...string) (string, error) {
	v := q.Get(key)
	if v == "" {
		return def, nil
	}
	for _, a := range allowed {
		if v == a {
			return v, nil
		}
	}
	return "", fmt.Errorf("%s must be one of %s", key, strings.Join(allowed, ", "))
}

Three principles are in there. An absent parameter gets a default, so clients that send nothing get sensible behavior. A present-but-bad parameter gets an error, not a silent fallback to the default: a client that sends page=abc has a bug, and hiding it by returning page 1 makes the bug harder to find. And allowlists (enumParam) beat deny lists for anything that ends up in a query, a sort clause or a file name. Never put a raw parameter into SQL; map the allowlisted sort value to a fixed column name in code.

A pagination handler

Putting it together, a list endpoint with page, page size, sort order, a boolean filter and repeated tags:

main.goGo
package main

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

type listParams struct {
	Page, PerPage int
	Sort          string
	Archived      bool
	Tags          []string
}

func parseList(r *http.Request) (listParams, error) {
	q := r.URL.Query()
	var p listParams
	var err error
	if p.Page, err = intParam(q, "page", 1, 1, 10000); err != nil {
		return p, err
	}
	if p.PerPage, err = intParam(q, "per_page", 20, 1, 100); err != nil {
		return p, err
	}
	if p.Sort, err = enumParam(q, "sort", "created", "created", "-created", "title"); err != nil {
		return p, err
	}
	if p.Archived, err = boolParam(q, "archived", false); err != nil {
		return p, err
	}
	p.Tags = q["tag"]
	return p, nil
}

func main() {
	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p, err := parseList(r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		json.NewEncoder(w).Encode(p)
	})
	for _, target := range []string{
		"/posts",
		"/posts?page=3&per_page=50&sort=-created&archived=yes&tag=go&tag=http",
		"/posts?per_page=100000",
		"/posts?page=abc",
		"/posts?sort=password",
		"/posts?archived=maybe",
	} {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", target, nil))
		fmt.Printf("%-72s -> %d %s", target, rec.Code, rec.Body.String())
	}
}
Text
/posts                                                                   -> 200 {"Page":1,"PerPage":20,"Sort":"created","Archived":false,"Tags":null}
/posts?page=3&per_page=50&sort=-created&archived=yes&tag=go&tag=http     -> 200 {"Page":3,"PerPage":50,"Sort":"-created","Archived":true,"Tags":["go","http"]}
/posts?per_page=100000                                                   -> 400 per_page must be between 1 and 100
/posts?page=abc                                                          -> 400 page must be an integer
/posts?sort=password                                                     -> 400 sort must be one of created, -created, title
/posts?archived=maybe                                                    -> 400 archived must be true or false

A request with no parameters gets the defaults; the full request parses every field including both tags; and each of the four bad requests is a 400 with a message naming the offending parameter. per_page=100000 is rejected instead of clamped, which is a choice: clamping to the maximum is friendlier to casual clients and hides mistakes from careful ones. I reject, because API clients are programs, and a program that asks for 100,000 has a bug. If you do clamp, document it and return the effective value in the response.

A few extra words on a design question that comes up in every list endpoint: page numbers or cursors. The page and per_page pair above is simple and fine for small collections, but OFFSET pagination gets slower as the page number grows and shows duplicates or gaps when rows are inserted between requests. For large or fast-changing collections, a cursor parameter (?after=<opaque token>) that encodes the last item seen is the better contract. The parsing helpers are the same; only the meaning of the parameter changes.

Limits, encoding and edge cases

Size. The request line and headers together are bounded by Server.MaxHeaderBytes (1 MiB by default), which bounds the query string too. Form bodies are not bounded by default; wrap the body with http.MaxBytesReader before calling ParseForm (limiting request bodies). ParseMultipartForm takes a memory limit, and larger parts spill to temporary files (file uploads).

Semicolons. Before Go 1.17 a semicolon separated query parameters like an ampersand. Now it is not a separator, and a query containing one is reported as an error by ParseQuery, so ?a=1;b=2 is a single odd parameter. http.AllowQuerySemicolons restores the old behavior for legacy clients.

Errors from malformed encoding. r.URL.Query() silently drops pairs it cannot parse (a bad percent-escape), while url.ParseQuery(r.URL.RawQuery) returns the error. If you want to reject a malformed query with a 400, parse it yourself and check the error.

Building URLs. Use url.Values to construct queries. Encode escapes values and sorts the keys, so the same parameters always produce the same string, which keeps cache keys and test expectations stable:

Go
v := url.Values{}
v.Set("q", "go & http")
v.Add("tag", "a")
v.Add("tag", "b")
fmt.Println(v.Encode()) // q=go+%26+http&tag=a&tag=b

Privacy. Query strings end up in logs, browser history and Referer headers. Tokens, passwords and personal data do not belong in them; put those in the body or a header (cookies, authentication).

The reference documentation is the net/url package for Values, and the net/http request methods for the form functions; the URL syntax itself is defined in RFC 3986. The site's query string to JSON tool is a quick way to inspect a tricky query.

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.