JavaRush /Courses /Go SELF /Query params validation and defaults

Query params validation and defaults

Go SELF
Level 57, Lesson 3
Available

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
limit
int
20
1..100 (for example)
offset
int
0
0.. (non-negative)
done
“optional bool” do not apply the filter only true/false allowed
sort
string
"id"
only from a whitelist: "id", "title", "created_at" (example)
order
string
"asc"
only "asc" or "desc"
q
string
""
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.

1
Task
Go SELF, level 57, lesson 3
Locked
Result limit
Result limit
1
Task
Go SELF, level 57, lesson 3
Locked
Done flag
Done flag
1
Task
Go SELF, level 57, lesson 3
Locked
Sorting the list
Sorting the list
1
Task
Go SELF, level 57, lesson 3
Locked
Listing contract
Listing contract
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION