← Back to the section

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 camelCasecustomerId, 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.

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 and r.Form["key"] for arrays (after r.ParseForm()).
  • Parameter names are camelCase: customerId, pageSize, createdFrom.
  • page starts at 1; page=0 is 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 / To suffixes — createdFrom, amountTo.
  • Complex search with multiple conditions → POST /resources/search with a JSON body.
  • URL and resources — path parameters and route structure in chi.
  • JSON and response format — the PageResponse format with the content field.
  • RFC 9457 errors — how to return parameter validation errors.