When a client makes a request like GET /orders?status=NEW&page=2&sort=createdAt,desc, everything after the question mark is called query parameters. In Go they aren't parsed automatically, as in some other frameworks: you have to pull them out of the request object manually. Let's figure out how to do it right.
How to read parameters from the request
In the standard net/http, all query parameters are available through r.URL.Query():
// GET /orders?customerId=42&status=NEW
customerID := r.URL.Query().Get("customerId") // "42"
status := r.URL.Query().Get("status") // "NEW"
If a parameter is missing, Get returns an empty string. Simple enough.
An important point about names: parameters are written in camelCase — customerId, pageSize, createdFrom. Variants like customer_id or CustomerID are a mistake: they break the API's consistency and will break clients that already use camelCase.
Request structure with filters
It's convenient to collect all parameters into a single struct — that way the function signature stays clean and all parameters are visible at once:
type ListOrdersQuery struct {
CustomerID string `schema:"customerId"`
Statuses []string `schema:"status"`
CreatedFrom string `schema:"createdFrom"`
CreatedTo string `schema:"createdTo"`
AmountFrom int64 `schema:"amountFrom"`
AmountTo int64 `schema:"amountTo"`
Q string `schema:"q"`
Sort string `schema:"sort"`
Page int `schema:"page"`
Size int `schema:"size"`
}
Ranges are expressed as a pair of fields with the From / To suffixes: createdFrom and createdTo, amountFrom and amountTo. It's a predictable pattern — the client immediately understands how to filter by an interval.
Parsing and validating parameters
Numbers, arrays, and optional values require explicit handling. Here's a complete parsing function:
func parseListOrdersQuery(r *http.Request) (ListOrdersQuery, []Violation) {
if err := r.ParseForm(); err != nil {
return ListOrdersQuery{}, []Violation{{Field: "", Code: "INVALID_QUERY", Message: "malformed query string"}}
}
q := ListOrdersQuery{Page: 1, Size: 20}
var violations []Violation
q.Statuses = r.Form["status"]
q.CustomerID = r.URL.Query().Get("customerId")
q.CreatedFrom = r.URL.Query().Get("createdFrom")
q.CreatedTo = r.URL.Query().Get("createdTo")
q.Q = r.URL.Query().Get("q")
q.Sort = r.URL.Query().Get("sort")
if v := r.URL.Query().Get("page"); v != "" {
page, err := strconv.Atoi(v)
if err != nil || page < 1 {
violations = append(violations, Violation{
Field: "page", Code: "INVALID_VALUE",
Message: "page must be >= 1",
})
} else {
q.Page = page
}
}
if v := r.URL.Query().Get("size"); v != "" {
size, err := strconv.Atoi(v)
if err != nil || size < 1 || size > 100 {
violations = append(violations, Violation{
Field: "size", Code: "INVALID_VALUE",
Message: "size must be between 1 and 100",
})
} else {
q.Size = size
}
}
if v := r.URL.Query().Get("amountFrom"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
violations = append(violations, Violation{
Field: "amountFrom", Code: "INVALID_VALUE",
Message: "amountFrom must be an integer",
})
} else {
q.AmountFrom = n
}
}
return q, violations
}
func listOrders(w http.ResponseWriter, r *http.Request) {
q, violations := parseListOrdersQuery(r)
if len(violations) > 0 {
writeValidationProblem(w, violations, traceIDFromCtx(r.Context()))
return
}
result, err := svc.ListOrders(r.Context(), q)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toPageResponse(result))
}
A few things worth paying attention to:
page starts at 1, not 0. This matters: page=0 is an invalid request, and you should return 400 Bad Request. Zero-based numbering goes against most clients' expectations.
size is capped. Without a cap, a client could accidentally request a million records — this is protection against overload.
Defaults are set up front. Page: 1, Size: 20 — if the client didn't pass a parameter, reasonable values are used.
Arrays: repeated parameter, not commas
A typical mistake when passing multiple values is to separate them with a comma:
GET /orders?status=NEW,PAID ← wrong
The correct way is to repeat the parameter:
GET /orders?status=NEW&status=PAID ← correct
In Go this is read through r.Form["status"] (not through .Get):
if err := r.ParseForm(); err != nil { /* ... */ }
statuses := r.Form["status"] // ["NEW", "PAID"]
The comma variant travels poorly between frameworks and makes values ambiguous — what if the value itself contains a comma?
Pagination
Offset pagination (page + size)
The simplest option: the client passes a page number and a size:
GET /orders?page=1&size=20
The response returns the total number of records and the contents of the page:
{
"content": [...],
"page": 1,
"size": 20,
"totalElements": 157,
"totalPages": 8
}
Suitable for small datasets. On large tables offset pagination slows down, because the database still has to scan through the skipped rows.
Cursor pagination
Instead of a page number, the client receives an opaque token (cursor) and passes it in the next request:
GET /products → returns nextCursor: "eyJpZCI6NTB9"
GET /products?cursor=eyJpZCI6NTB9 → next page
The key word is opaque. The client doesn't know what's inside the token (it could be base64 of an ID, a timestamp, or anything else). It just passes it back as-is:
type ListProductsQuery struct {
Cursor string `schema:"cursor"`
Size int `schema:"size"`
}
type CursorPageResponse[T any] struct {
Content []T `json:"content"`
NextCursor string `json:"nextCursor,omitempty"`
HasMore bool `json:"hasMore"`
}
func listProducts(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
size := 20
if v := r.URL.Query().Get("size"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 100 {
size = n
}
}
result, err := svc.ListProducts(r.Context(), cursor, size)
if err != nil {
httperr.Write(w, r, err)
return
}
resp := CursorPageResponse[ProductResponse]{
Content: toProductResponses(result.Items),
HasMore: result.HasMore,
}
if result.HasMore {
resp.NextCursor = result.NextCursor
}
writeJSON(w, http.StatusOK, resp)
}
Cursor pagination works stably even under active writes to the database: the page doesn't "shift" if new records were added between requests.
Sorting
The sort parameter takes field,direction pairs separated by a comma. For multiple fields, repeat the parameter:
GET /orders?sort=createdAt,desc
GET /orders?sort=total,asc&sort=createdAt,desc
Parsing:
func parseSortParam(sortParam []string) []SortField {
var fields []SortField
for _, s := range sortParam {
parts := strings.Split(s, ",")
if len(parts) != 2 {
continue
}
field, dir := parts[0], parts[1]
if dir != "asc" && dir != "desc" {
continue
}
fields = append(fields, SortField{Field: field, Dir: dir})
}
return fields
}
sortParams := r.Form["sort"]
sorts := parseSortParam(sortParams)
Invalid direction values are ignored — the server doesn't crash on an unexpected parameter.
Full-text search
For searching by text, the q parameter is used:
GET /customers?q=Ivanov
func listCustomers(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
page := 1
size := 20
result, err := svc.SearchCustomers(r.Context(), q, page, size)
// ...
}
When you need POST /search instead of GET
Query parameters work well for simple filtering. But when the search logic becomes complex — many filters, nested conditions, large lists of IDs — a GET request with a long parameter string becomes unwieldy and may run into the URL length limit.
In such cases you create a separate endpoint POST /resources/search with a JSON body:
r.Post("/orders/search", searchOrders)
type SearchOrdersRequest struct {
CustomerIDs []string `json:"customerIds"`
Statuses []string `json:"statuses"`
AmountFrom int64 `json:"amountFrom"`
AmountTo int64 `json:"amountTo"`
Tags []string `json:"tags"`
Page int `json:"page"`
Size int `json:"size"`
}
func searchOrders(w http.ResponseWriter, r *http.Request) {
var req SearchOrdersRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
if req.Page < 1 {
req.Page = 1
}
if req.Size < 1 || req.Size > 100 {
req.Size = 20
}
result, err := svc.SearchOrders(r.Context(), req)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toPageResponse(result))
}
Another signal that you need POST /search: when a filter contains action logic — for example, ?action=confirm. Such things are modeled as separate endpoints (POST /orders/{id}/confirm), not hidden inside query parameters.
Common mistakes
Names in snake_case or PascalCase. customer_id or CustomerID is a mistake. Correct: customerId.
page=0. Page numbering starts at 1. page=0 is an invalid request, return 400.
Array via comma. ?status=NEW,PAID is a mistake. Correct: ?status=NEW&status=PAID, read through r.Form["status"].
Trying to parse the cursor on the client. The cursor is an opaque token. Its structure may change in any version of the server. The client just passes it back as a string.
In short
- Query parameters are read through
r.URL.Query().Get()for single values andr.Form["key"]for arrays (afterr.ParseForm()). - Parameter names are camelCase:
customerId,pageSize,createdFrom. pagestarts at 1;page=0is an invalid request.- Arrays are passed by repeating the parameter:
?status=NEW&status=PAID, not via comma. - Cursor pagination is preferable for large datasets; the cursor is opaque — the client doesn't parse its contents.
- Ranges: the
From/Tosuffixes —createdFrom,amountTo. - Complex search with multiple conditions →
POST /resources/searchwith a JSON body.
What to read next
- URL and resources — path parameters and route structure in chi.
- JSON and response format — the
PageResponseformat with thecontentfield. - RFC 9457 errors — how to return parameter validation errors.