1. Why validate query params and set defaults
If you have ever written GET /api/v1/tasks?limit=abc and wondered why the server behaves “kind of weird” — congratulations: you have already met the need for validation. Query parameters arrive as strings, and the server must decide: is it “missing”, “valid”, or an “input error”. Defaults are not just convenience here; they are a way to make behavior stable.
Imagine we have an endpoint that returns a list of tasks:
GET /api/v1/tasks
and it has these parameters:
- limit — how many tasks to return,
- offset — which position to start from,
- done — status filter,
- sort and order — sorting,
- q — substring to search for.
All of these values come in as strings. And without careful parsing you will almost certainly end up with one of two “masterpieces”: either the server will silently ignore errors (the user gets angry because “but I passed the parameter!”), or it will crash (now DevOps is angry, and a bit of you too).
Defaults are also a contract. If limit is not provided, we must decide in advance: is it 20? 50? 100? If sort is not provided — do we sort by id? If done is not provided — do we show both completed and incomplete? This must be defined as explicitly as the JSON format in the body.
2. A mini contract for parameters of /api/v1/tasks
To validate, we need rules. In an API it is useful to keep a small table “parameter → type → default → constraints”. It helps during implementation, while reading code, and when writing tests.
| Parameter | Type after parsing | If not set | Constraints / comments |
|---|---|---|---|
|
|
|
1..100 (for example) |
|
|
|
0.. (non-negative) |
|
“optional bool” | do not apply the filter | only true/false allowed |
|
|
|
only from a whitelist: "id", "title", "created_at" (example) |
|
|
|
only "asc" or "desc" |
|
|
|
you can TrimSpace, an empty string = “no filter” |
Pay attention to done: it is not just a bool. It is important for us to distinguish “done=false” from “the done parameter was not provided at all”. If you store this as a regular bool, then both cases become false and you lose information. And then the magic begins: “why did my ‘not done’ filter turn on by itself?”.
3. A general parsing and validation pattern
Good news: validating query parameters almost always fits into one repeatable pattern. Bad news: if you do not extract it into small functions, it will turn into 70 lines of if where nobody wants to live, including you.
Conceptually, the process looks like this:
flowchart TD
A["r.URL.Query() → url.Values"] --> B["Extract raw strings"]
B --> C["Parse types: int/bool"]
C --> D["Validate: ranges/whitelist"]
D --> E["Apply defaults"]
E --> F["Get a struct with parameters"]
The key idea of this lecture: parameter validation should be a separate small layer that knows nothing about the database, does not sort real tasks, and does not build JSON. It simply turns “strings from the query” into “typed request settings”, or returns a clear error.
4. Get() and parameter presence: missing vs empty value
The most common surprise for a beginner: q.Get("limit") returns an empty string both when the parameter is absent and when the parameter is present but empty (?limit=). Sometimes that is fine; sometimes it is critical.
So for parameters where “presence” matters, it is more convenient to look directly in the map:
package main
import (
"fmt"
"net/url"
)
func main() {
q, _ := url.ParseQuery("done=&limit=20")
_, hasDone := q["done"]
_, hasOffset := q["offset"]
fmt.Println("has done:", hasDone) // has done: true
fmt.Println("has offset:", hasOffset) // has offset: false
}
Here we separated “the key is missing” from “the key is present”. After that you can decide what to do with an empty value: treat it as an error, or interpret it as “not set”.
A practical rule: for parameters with a default (limit, offset) it is often reasonable to treat an empty string as “as if not set” (i.e., apply the default). But for filter parameters (done) an empty value more often looks like an input error. And this is not “whatever happens” — it is that contract decision again.
5. Parsing numbers: limit and offset
Numeric parameters are the main source of trouble because they affect load. limit=1000000 can turn “a simple task list” into “why is the database smoking”. So Atoi alone is not enough: you need constraints.
Let’s start with a small helper function: “take an int; if not set — default; if set — parse and check the range”.
package main
import (
"fmt"
"net/url"
"strconv"
)
func parseIntInRange(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 in range %d..%d", key, min, max)
}
return n, nil
}
Notice: we return 0 on error not because 0 “means error”, but because the value will not be used anyway if err != nil. This is a normal Go approach.
Now let’s apply this to limit and offset. offset usually has no upper bound at the API level, but sometimes it is set (for example, to prevent “scrolling” through the database for millions of rows). For simplicity we will constrain only limit, and for offset we will check non-negativity.
package main
import (
"fmt"
"net/url"
)
func main() {
q, _ := url.ParseQuery("limit=50&offset=0")
limit, err := parseIntInRange(q, "limit", 20, 1, 100)
fmt.Println(limit, err) // 50 <nil>
offset, err := parseIntInRange(q, "offset", 0, 0, 1_000_000)
fmt.Println(offset, err) // 0 <nil>
}
Yes, we suddenly invented a max for offset. That is fine: an API contract is not a law of nature; you choose it. The important part is that the choice is conscious and stable.
6. Parsing bool: do not confuse done=false with missing done
With bool there are two tasks. The first is to parse a string into a bool (and not accept “yes/1/okey-dokey” unless you agreed to that). The second is to preserve the fact that the parameter was present.
One simple option is to return a triple (value, present, err).
package main
import (
"fmt"
"net/url"
"strconv"
)
func parseOptionalBool(q url.Values, key string) (value bool, present bool, err error) {
raw, ok := q[key]
if !ok {
return false, false, nil
}
if len(raw) == 0 || raw[0] == "" {
return false, true, fmt.Errorf("%s must be true or false", key)
}
v, err := strconv.ParseBool(raw[0])
if err != nil {
return false, true, fmt.Errorf("%s must be true or false", key)
}
return v, true, nil
}
Why do we not use Get? Because it is important to distinguish a missing key (ok == false) from an empty value (?done=). Also because raw is a list of values, so we do not forget that url.Values is “multi-valued” by nature.
Quick check:
package main
import (
"fmt"
"net/url"
)
func main() {
q1, _ := url.ParseQuery("")
v, ok, err := parseOptionalBool(q1, "done")
fmt.Println(v, ok, err) // false false <nil>
q2, _ := url.ParseQuery("done=false")
v, ok, err = parseOptionalBool(q2, "done")
fmt.Println(v, ok, err) // false true <nil>
}
Now in business logic we can make an honest decision: if present == false, we do not apply the done filter. If present == true, we apply the filter using the value.
7. A whitelist for strings: sort and order
String parameters are dangerous for another reason: they often affect SQL/sorting. Even if you do not write SQL by hand, allowing “any string” for sort is an invitation to weird cases. Your code should be boring: “allow only what is known”.
Let’s build a sorting parser with a default and a whitelist. Note that it is convenient to store the whitelist in a map[string]bool: fast and readable.
package main
import (
"fmt"
"net/url"
"strings"
)
func parseSort(q url.Values) (string, error) {
raw := strings.TrimSpace(q.Get("sort"))
if raw == "" {
return "id", nil
}
allowed := map[string]bool{
"id": true,
"title": true,
"created_at": true,
}
if !allowed[raw] {
return "", fmt.Errorf("sort must be one of: id, title, created_at")
}
return raw, nil
}
And separately — order. Here the whitelist is even simpler:
package main
import (
"fmt"
"net/url"
"strings"
)
func parseOrder(q url.Values) (string, error) {
raw := strings.TrimSpace(q.Get("order"))
if raw == "" {
return "asc", nil
}
if raw != "asc" && raw != "desc" {
return "", fmt.Errorf("order must be asc or desc")
}
return raw, nil
}
Yes, this is boring code. And that is a compliment: boring code usually does not wake you up at night.
8. Putting everything into one struct
When we have small parsers, we can assemble the final result into a struct. This struct is a good candidate to pass further into the application layer (use cases), because it is already typed and valid.
Let’s create TaskListQuery:
package main
type TaskListQuery struct {
Limit int
Offset int
DoneValue bool
DonePresent bool
Sort string
Order string
Q string
}
Note: we are not using pointers and are not going into “nullable bool” via *bool, so we do not provoke extra questions. The pair (DoneValue, DonePresent) is usually simpler for a beginner: two fields, both clear.
Now the parseTaskListQuery function:
package main
import (
"net/url"
"strings"
)
func parseTaskListQuery(q url.Values) (TaskListQuery, error) {
limit, err := parseIntInRange(q, "limit", 20, 1, 100)
if err != nil {
return TaskListQuery{}, err
}
offset, err := parseIntInRange(q, "offset", 0, 0, 1_000_000)
if err != nil {
return TaskListQuery{}, err
}
doneValue, donePresent, err := parseOptionalBool(q, "done")
if err != nil {
return TaskListQuery{}, err
}
sortBy, err := parseSort(q)
if err != nil {
return TaskListQuery{}, err
}
order, err := parseOrder(q)
if err != nil {
return TaskListQuery{}, err
}
return TaskListQuery{
Limit: limit,
Offset: offset,
DoneValue: doneValue,
DonePresent: donePresent,
Sort: sortBy,
Order: order,
Q: strings.TrimSpace(q.Get("q")),
}, nil
}
Notice an important style: early return on error. This is the Go pattern that makes code linear and readable. If you try to “collect all errors into one variable and return at the end”, it becomes hard to read and easy to break.
9. In the handler: parsing and error responses
How it looks in a handler
When you write an HTTP handler, you already have an *http.Request, and inside it you have r.URL and r.URL.Query(). Our parsing function fits perfectly there: the handler simply calls it, and then decides what to do with the error.
package main
import (
"fmt"
"net/http"
)
func listTasksHandler(w http.ResponseWriter, r *http.Request) {
query, err := parseTaskListQuery(r.URL.Query())
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Fprintf(w, "limit=%d offset=%d sort=%s order=%s\n",
query.Limit, query.Offset, query.Sort, query.Order,
)
}
Here http.Error is used as a simple illustration: “if it is an input error — return 400”. In a real application you will most likely respond with JSON using a single error format, but right now the mechanics matter more: an error at the query validation stage is a request error, not “oops, the server broke”.
Error messages: short, but useful
An error message is part of UX. Even if this is an API “only for our frontend”, a week later the frontend will forget what it was sending, and you will be the one debugging it anyway.
A good message for a query error usually contains the parameter name and the expectation. For example, limit must be in range 1..100 is already enough to quickly fix the request. A bad message looks like invalid input, because it says nothing other than “go guess”.
One more nuance: if a parameter is missing and we have a default, that is not an error. The error starts where the parameter is present but cannot be correctly interpreted according to the contract.
10. Typical mistakes when validating query params and working with defaults
Mistake #1: “Parsed with Atoi — and that’s enough”.
It often seems that if strconv.Atoi("500") succeeds, then everything is fine. But without a range check, limit=500 turns a list endpoint into “dump everything for me”. Even if you have 10 tasks now, later you will have 10 thousand, and you will suddenly start suffering in production.
Mistake #2: Losing the difference between “parameter is missing” and “value = false/0”.
If you store done as a regular bool, then a missing parameter and done=false become the same thing. As a result, the API starts “filtering by not done” even when the user did not ask for a filter. For such parameters, store a presence flag (present) or use another explicit mechanism.
Mistake #3: Using Get() where “presence” matters.
q.Get("done") returns an empty string both when the parameter is missing and when it is present but empty. If you want to distinguish these cases, look at q["done"] and check ok, as well as the list length.
Mistake #4: Allow any sort and then “handle it somehow”.
Sorting should be whitelisted. Otherwise you get unstable behavior, strange code branches, and (in more realistic implementations) the risk of unsafe construction of sort expressions. Validation should reject unknown values immediately.
Mistake #5: Mix parsing, validation, and business logic in one place.
When a handler simultaneously reads the query, parses limit, hits storage, sorts, filters, and writes JSON, it becomes hard to test and easy to break. Keep the “query → typed struct” layer separate: it is small, repeatable, and very testable.
GO TO FULL VERSION