JavaRush /Courses /Go SELF /Query params — filters, sorting, pagination

Query params — filters, sorting, pagination

Go SELF
Level 57, Lesson 1
Available

1. Query params as part of the contract

When you first see /api/v1/tasks?done=true&limit=20, it’s tempting to think: “Well, it’s just the string after ?, what difference does it make?” The difference is huge: query params are a way to configure the output without changing the resource itself. The resource stays “task list”, but we tell the server which exact list we need and how to present it.

For the task list resource /api/v1/tasks, three natural “client needs” almost always come up.

First: filter the list — show only completed or only incomplete, search by substring, pick tasks with specific tags. Second: sort — by id, by title (and not be surprised that today it’s one order and tomorrow it’s another). Third: limit the volume — get 20 tasks instead of 2000, i.e., pagination.

It’s important to lock in the right philosophy: query params control the list representation, but they do not replace resource identification. If you must specify “which exact task”, that’s usually a path (/api/v1/tasks/{id}), not a query. Query is “output settings”.

2. A parameter dictionary for /api/v1/tasks

When you start adding parameters “one by one”, it’s easy to end up with chaos: today done, tomorrow isDone, the day after tomorrow completed — and the client ends up living in a world of pain and copy-paste. So we do the grown-up thing: we fix a set of parameters as a contract dictionary for task listing. It should be short, stable, and not try to “cram all of SQL into one string”.

Below is a basic contract (enough for a learning project and for almost any real simple API). We intentionally keep it minimal: fewer parameters means easier support and fewer surprises.

Parameter Type (after parsing) Meaning Default value (if missing) Example
done
bool
(but the parameter is optional)
Filter by completion status no filter applied
done=true
q
string
Text search (e.g., in title) empty search (don’t filter)
q=milk
tag
[]string
(repeating key)
Filter by tags don’t filter by tags
tag=go&tag=http
sort
enum (
id
,
title
)
Which field to sort by
id
sort=title
order
enum (
asc
,
desc
)
Sort direction
asc
order=desc
limit
int
How many items to return
20
limit=50
offset
int
Where to start from
0
offset=100

A couple of important semantic notes.

First, done is an optional filter. If done is not provided, it does not mean done=false; it means “show everything”. We’ll come back to this, because it’s a common source of classic bugs.

Second, tag is a repeating parameter, and this is a normal, “HTTP-style” way to pass a list. Not a homemade CSV in one string, but exactly tag=go&tag=http.

3. Parameter design nuances

Parameter naming

With API names, the compiler usually doesn’t break — people do. You name a parameter once, the client hardcodes it into a mobile app, and suddenly it turns out you can’t rename it “painlessly”. So treat query param naming like a public interface: boring, careful, predictable.

A simple rule works here: the parameter name should be short, clear, and without “creative” flair. We use lowercase and plain words: done, q, tag, sort, order, limit, offset. This is not “self-expression”; it’s a contract.

Separately about q. Beginners sometimes think: “Why not query?” You can use query, but the short q is a familiar pattern (search forms, many APIs). The key is to pick one and stick to it.

About multi-word names. If you do need them, it’s better to stick to snake_case (e.g., created_at, due_before), but in our current contract version we intentionally keep only single-word parameters to avoid unnecessary complexity.

Filters

Filters are where the user says: “I don’t need the whole task list, I need a subset.” And this is where it’s easy to accidentally build an API that “works”, but cannot be explained or tested. So filters must be simple and composable.

The simplest and clearest composition model is: all filters apply at once, i.e., logical AND.

For example, the request:

/api/v1/tasks?done=false&q=milk&tag=home&tag=shopping

is reasonably read as: “Give me incomplete tasks where the text contains milk, and that have tags home and shopping.” You can argue whether tags should mean AND or OR — and that is exactly a contract detail you should document in words.

For a learning project, it’s convenient to adopt this rule: if multiple tag values are provided, a task matches if it contains at least one of the tags (OR within tags), while still being AND with respect to other filters. So: “done filter AND search AND (tag1 OR tag2 OR ...)”. This is easier to explain to users and simpler to implement.

Within this lecture we won’t write the filtering code, but we are already designing the behavior to be predictable.

Sorting

Sorting feels simple until you realize that “by default” is also sorting, just implicit. And implicit sorting usually means “whatever happens”: today the DB returns one order, tomorrow a different one, and client tests start doing a “flaky dance”.

So a task list must have a defined deterministic default order. In our contract that’s sort=id and order=asc. Why id? Because it’s the most stable and unambiguous field in basic CRUD.

It’s also useful to mentally agree on a tie-breaker (a secondary sort key). Even if we sort by title, two tasks can have the same title. Then the order still needs to be stable — usually id becomes the second key. This is an implementation detail, but it’s good to keep in mind; otherwise sorting by title will “jump around”, and users will think the API has a mind of its own.

The sort and order parameters are designed as a pair: sort answers “by what”, order answers “in which direction”. This is simpler than trying to cram it into one parameter like sort=title_desc.

Pagination

Pagination is not only a UI convenience, but also protection for the server (and the client) against accidental “dump me all tasks ever”. If someone has ever run SELECT * without LIMIT on a million-row table, they start respecting pagination almost religiously (and that’s fine).

We use a simple limit/offset model. It reads like this: offset is how many to skip from the start of the sorted list, limit is how many to return after that.

For example: /api/v1/tasks?sort=id&order=asc&limit=20&offset=40

means: “Return tasks from 41 to 60 in ascending id order.” And an important thought right away: pagination only makes sense together with a fixed sort. Otherwise “pages” can get mixed up.

The contract should define reasonable constraints. For example, default limit is 20, and max limit is 100. This isn’t “the instructor being picky”; it’s normal practice. If a client suddenly needs to export 5000 tasks, that’s a different scenario, and it should be designed intentionally (and often differently).

Repeating parameters

Repeating query params can look odd to people used to “passing arrays” in JSON. But in URLs it’s a natural pattern: one key can have multiple values, because a query string is conceptually closer to map[string][]string than to map[string]string.

So tag=go&tag=http is not a “hack” or a “crutch”; it’s a standard way to express a list. You’ll see it in all kinds of systems, from simple REST APIs to complex search queries.

Practically, it gives you a nice property: if the client wants to add a tag, it doesn’t need to parse a CSV string, carefully escape commas, and think about what to do with a tag like dev,ops. It just adds one more tag.

In the Go standard library, this is modeled exactly like that: parsing the query produces a structure where a key can have multiple values, and then code can take the first value or all values at once. Even in examples where one value is read via r.URL.Query().Get("guess"), the assumption is that a query is a set of parameters that the server reads in a structured way, not “split by ampersand”.

4. Request examples for /api/v1/tasks

Once the contract is fixed, it becomes easier to “think in URLs”. This is a useful skill: you look at a link and understand what the server must do, without guessing or magic.

Here are a few examples (and how to interpret them correctly).

package main

import "fmt"

func main() {
	fmt.Println("/api/v1/tasks") // task list without filters, sort=id, order=asc, limit=20, offset=0
}

There is no query here at all. And that’s fine: defaults are part of the contract, and the absence of parameters must have a clear meaning.

package main

import "fmt"

func main() {
	fmt.Println("/api/v1/tasks?done=true") // only completed, other parameters are default
}

done=true applies the filter, but does not change sorting or pagination.

package main

import "fmt"

func main() {
	fmt.Println("/api/v1/tasks?q=milk&tag=home&tag=shopping") // search + tags
}

We read this as: “search for milk and filter by tags.” How multiple tags are interpreted (OR or AND) is fixed in the contract ahead of time, not “whatever happens”.

package main

import "fmt"

func main() {
	fmt.Println("/api/v1/tasks?sort=title&order=desc&limit=10&offset=20") // sorting + pagination
}

Here the request already looks like a “full” client-side listing: sort by title descending and take a page.

5. A parameter model in code

At this point you want to immediately parse limit via strconv.Atoi and validate sort via a whitelist. But per our plan, those are next steps. Here we do exactly one useful thing: we design a type in code that stores typed listing parameters.

This helps avoid mixing “reading parameters” with “task list business logic”. We want to get to a scheme: first parse and validate the input, get a parameter struct, and only then pass it to a function that knows how to select tasks.

package main

type TaskListQuery struct {
	// Done: nil = filter not set, true/false = filter set.
	Done *bool

	Q    string   // search string
	Tags []string // repeating tag=...

	Sort  string // "id" or "title"
	Order string // "asc" or "desc"

	Limit  int
	Offset int
}

Notice *bool. This isn’t “Go’s love for pointers”; it’s a way to honestly store three states: “not provided”, “true”, “false”. If you use a plain bool, you can’t distinguish done=false from “done was not sent”. And that’s a different meaning.

Now let’s set defaults. Defaults are easier to manage centrally than to scatter 20 and id through the code like confetti.

package main

func DefaultTaskListQuery() TaskListQuery {
	return TaskListQuery{
		Done:   nil,
		Q:      "",
		Tags:   nil,
		Sort:   "id",
		Order:  "asc",
		Limit:  20,
		Offset: 0,
	}
}

And now we have a clear boundary: the query params parser should take DefaultTaskListQuery(), apply what came from the client, and either return a ready struct or a validation error. We’ll implement parsing later (and that’s where url.Values, Get, value lists, and so on will show up).

By the way, the Go standard library demonstrates that “parse query → get string → convert → validate” is a perfectly normal path: first you read the string value from the query, then turn it into a number and check bounds. And if you need to parse a query as a standalone string (without a full URL), there’s a standard tool for that too: parsing a query string and then calling Encode() shows up even in testing examples.

6. Typical mistakes when designing query params

Mistake No. 1: making required parameters in query.
If the client must pass id as ?id=123, you’re almost certainly making life harder: this is resource identification, not output configuration. Identification belongs in the path; keep query for filters/sorting/pagination.

Mistake No. 2: not fixing defaults and “hoping for common sense”.
Without defaults the contract turns into guesswork: the client doesn’t know how many items will be returned, in what order, or what “parameter missing” means. A default is not a small detail; it’s part of the public API behavior.

Mistake No. 3: confusing “parameter is missing” with “parameter equals false/0/empty”.
The classic case is done=false versus a missing done. If you store done as a bool, you inevitably end up with logic like “how do we know whether it was passed”. As a result, code starts inventing heuristics and bugs appear “out of nowhere”.

Mistake No. 4: accepting any sort values and “handling it later”.
If the server silently accepts sort=banana and then either crashes or sorts “somehow by default”, the client won’t understand what they did wrong. Enums need a whitelist of allowed values; otherwise the contract becomes blurry.

Mistake No. 5: not limiting limit and not thinking about load.
limit=1000000 is not “the user wanted it”; it’s a potential attack or at least an accidental overload. Even in a learning project, it’s useful to think like an engineer: set reasonable bounds and explain them via a validation error.

1
Task
Go SELF, level 57, lesson 1
Locked
Task link generator
Task link generator
1
Task
Go SELF, level 57, lesson 1
Locked
Reading task filters
Reading task filters
1
Task
Go SELF, level 57, lesson 1
Locked
Filter normalization
Filter normalization
1
Task
Go SELF, level 57, lesson 1
Locked
Listing Simulator
Listing Simulator
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION