1. Why repeated parameters are normal
When you first see a URL like ...?tag=go&tag=http&tag=cli, it’s natural to ask: “Could we do one at a time, please?” But the web world agreed on this long ago: a repeated key in the query is the standard way to pass a list of values. And it makes sense: a query string is not JSON; it doesn’t have arrays as a structure, but it does have a simple rule: “a key may appear many times.”
Imagine our tasks API supports filtering by tags. The user wants “all tasks tagged go and http.” We have several options: do tags=go,http, do tags[]=go&tags[]=http, do filter=tags:go|http (dedicated to all regex lovers). But the simplest and most widely understood option is to repeat the key: tag=go&tag=http.
In Go this is especially convenient, because the standard library already stores the query as a structure that is designed for multiple values from the start: url.Values.
url.Values: a map with lists of values
In short: url.Values is a dictionary (map) where each key corresponds to a list of strings, not a single string. Conceptually, it’s basically map[string][]string. And that’s exactly why repeated parameters don’t look like “hacks” — they fit the data model naturally.
This is a very important mental shift: you stop thinking “the query is the string after ?,” and start thinking “the query is a structure ‘key → values.’” Then it becomes immediately easier to explain why Get returns only one value, why Add exists, and why Encode() exists at all.
Let’s sketch a small mapping picture:
| Query string | url.Values (idea) |
|---|---|
|
|
|
|
|
|
|
|
Note: even “regular” parameters are technically lists too — just lists of length 1.
2. Set and Add: overwrite or append
When you build a query, you almost always run into two scenarios. First: the parameter must be exactly one value, for example limit=20. Second: the parameter represents a list, for example tag=go&tag=http. For the first, there is Set; for the second — Add. And this is not “just two similar methods,” but a direct reflection of the contract.
Let’s start with the smallest example — build filters for /api/v1/tasks:
package main
import (
"fmt"
"net/url"
)
func main() {
q := url.Values{}
q.Set("done", "true")
q.Set("limit", "20")
fmt.Println(q.Encode()) // done=true&limit=20
}
Now let’s add tags. Here we care specifically about the “list” behavior, so we use Add:
package main
import (
"fmt"
"net/url"
)
func main() {
q := url.Values{}
q.Add("tag", "go")
q.Add("tag", "http")
fmt.Println(q.Encode()) // tag=go&tag=http
}
And this is where it becomes clear why url.Values stores []string: otherwise this would be impossible without custom formats.
Separately, it’s useful to remember: if you accidentally do Set("tag", "go"), and then Set("tag", "http"), then go will disappear — because Set means “keep only one value.”
4. Reading lists: Get vs vals["tag"]
When we read a query, we also have to remember “key → list of values.” That’s why Go has two main reading styles.
The first style is Get("k"). It’s convenient when the parameter is single by contract: limit, offset, sort, done. In HTTP code it looks very familiar: r.URL.Query().Get("guess") — the classic example of reading a single parameter.
The second style is vals["k"], i.e., direct access to the slice of values. You need it when the parameter is repeating by contract: tag, id (if you’re doing a batch), or something like status=...&status=....
Let’s show the difference on a small example:
package main
import (
"fmt"
"net/url"
)
func main() {
u, _ := url.Parse("/api/v1/tasks?tag=go&tag=http&tag=cli")
q := u.Query()
fmt.Println(q.Get("tag")) // go
fmt.Println(q["tag"]) // [go http cli]
}
Why does Get return only the first value? Because it’s designed as a “convenient thing for single parameters.” It’s not a design bug, it’s a choice: Get helps you avoid extra checks.
But that leads to an important contract-reading rule:
If a parameter is a “list” by contract, do not use Get, because you will silently lose data.
Losing data in filters is one of the nastiest bugs: the user passed two tags, the server applied one, and everything looks “like it works,” only the results are weird.
5. Escaping and Encode()
Now for the most “magical” part. At some point every beginner writes something like:
raw := "/api/v1/tasks?q=" + query
…and feels like a winner. Until the first request with a space, an ampersand, or a plus sign.
The problem is that the query string uses special characters:
- & separates parameters,
- = separates key from value,
- ? separates the path from the query,
- # starts the fragment (it should not end up in the query at all),
- spaces, +, %, and many other characters have special rules.
If you insert a value “as is,” it can “tear apart” the entire query structure. For example, the value go lang & http contains &, which is a parameter separator. If you glue it together by hand, you’ll get a mess.
That’s why you need percent-encoding (URL escaping): dangerous characters are turned into a safe textual form %XX. In Go, the standard library can do this for you via url.Values.Encode().
There is a very illustrative example: the standard workflow ParseQuery → Encode → ParseQuery should preserve the parameter structure.
Let’s look at escaping in action:
package main
import (
"fmt"
"net/url"
)
func main() {
q := url.Values{}
q.Set("q", "go lang & http")
fmt.Println(q.Encode()) // q=go+lang+%26+http
}
Notice two things.
First, spaces turned into +. This is normal behavior specifically for the query part of a URL (there, spaces are often encoded as +). Second, & turned into %26, so it won’t act as a parameter separator.
If it feels “not very readable,” that’s normal. URL encoding isn’t supposed to be human-readable; it’s supposed to be correct for machines. For humans, we have structures, logs, pretty JSON, etc.
Lists of values and Encode() in one URL
The nicest part: repeated parameters are automatically encoded correctly, and you don’t need to think about &, ordering, or escaping each value.
Let’s build a URL for a task list with filters:
- done=true
- tag=go and tag=http
- q=go lang & http (to verify escaping)
package main
import (
"fmt"
"net/url"
)
func main() {
u := url.URL{
Scheme: "https",
Host: "api.example.com",
Path: "/api/v1/tasks",
}
q := url.Values{}
q.Set("done", "true")
q.Add("tag", "go")
q.Add("tag", "http")
q.Set("q", "go lang & http")
u.RawQuery = q.Encode()
fmt.Println(u.String())
}
Two disciplines matter here.
First: RawQuery is filled without the ? sign. That is, you put in only a=b&c=d.
Second: we do not manually concatenate the ...? string at all — we build a structure (url.URL + url.Values) and trust Encode().
This is one of those cases where “less code” also means “fewer bugs.”
6. Details that save hours of debugging
This section collects details that often come up not while writing the code, but when you’re already sure everything is perfect… and then filters behave weirdly. This is a normal part of growing up any HTTP code: first you write “it works,” then you write “it always works.”
u.Query() returns a copy, and you must write it back
A very common trap: you parse a URL, get q := u.Query(), modify q, and expect u.String() to change. But Query() returns values that you then need to write back into u.RawQuery.
package main
import (
"fmt"
"net/url"
)
func main() {
u, _ := url.Parse("https://api.example.com/api/v1/tasks?limit=10")
q := u.Query()
q.Set("limit", "20")
fmt.Println(u.String()) // https://api.example.com/api/v1/tasks?limit=10
u.RawQuery = q.Encode()
fmt.Println(u.String()) // https://api.example.com/api/v1/tasks?limit=20
}
Keep this example in mind as a “ritual”: changed q — don’t forget u.RawQuery = q.Encode().
Encode() orders keys
In real projects, stable output matters: tests, logs, diffs. In Go, Values.Encode() makes the string deterministic by keys (usually that means sorting the keys). This isn’t cosmetic — it’s a reason URLs are easy to compare in tests.
This is also one reason you shouldn’t build queries by hand: you’ll break stability yourself.
Double escaping
Sometimes a developer “just in case” does something like url.QueryEscape(value), and then puts it into url.Values and calls Encode(). As a result, the string gets escaped twice, and on the server you get garbage like %2526 (that’s “encoded %26”).
The rule is simple: if you use url.Values.Encode(), do not manually escape values.
7. Building a query from options
To cement the idea at the “looks like real code” level, let’s make a small options struct for listing tasks and a function that turns it into a query string.
Important: we are not building an HTTP client here, not sending requests, and not discussing transport. We are just carefully assembling a URL so it is safe and clear.
package main
import (
"net/url"
"strconv"
)
type ListTasksOptions struct {
Done *bool
Tags []string
Limit int
Offset int
Query string
}
func buildTasksQuery(opt ListTasksOptions) url.Values {
q := url.Values{}
if opt.Done != nil {
q.Set("done", strconv.FormatBool(*opt.Done))
}
for _, tag := range opt.Tags {
q.Add("tag", tag)
}
if opt.Limit > 0 {
q.Set("limit", strconv.Itoa(opt.Limit))
}
if opt.Offset > 0 {
q.Set("offset", strconv.Itoa(opt.Offset))
}
if opt.Query != "" {
q.Set("q", opt.Query)
}
return q
}
Notice the style: Tags is a list, so we use Add. The other parameters are single by contract — so we use Set. And Done is a pointer *bool so you can distinguish “filter not set” from “filter set to false.”
Now let’s assemble the full URL:
package main
import (
"fmt"
"net/url"
)
func main() {
done := true
opt := ListTasksOptions{
Done: &done,
Tags: []string{"go", "http"},
Limit: 20,
Query: "go lang & http",
}
u := url.URL{
Scheme: "https",
Host: "api.example.com",
Path: "/api/v1/tasks",
}
u.RawQuery = buildTasksQuery(opt).Encode()
fmt.Println(u.String())
}
This approach is good because you can test buildTasksQuery separately and be confident that:
- a list becomes repeated parameters exactly,
- the query string is escaped correctly,
- empty values do not “clutter” the URL.
8. Common mistakes
Mistake #1: assuming the query is map[string]string.
It feels natural until you encounter repeated parameters. Then you start inventing custom formats: “separate by comma,” “separate by semicolon,” “and let’s also escape commas”… and suddenly you invented your own small RFC without even getting a medal. The correct model is url.Values as “key → list of values.”
Mistake #2: using Set where a list is required.
Tag filters are the classic example. In code it looks logical: “well, I’m setting tag.” But in reality you overwrite the previous value every time, and only the last tag makes it into the request. If a parameter repeats by contract, use Add when building, and vals["tag"] when reading.
Mistake #3: reading a list via Get and not noticing lost values.
Get is convenient, but it returns only the first value. If you read tag via Get, the user will think they passed two tags, and the server “for some reason ignores the second one.” This is especially nasty because there is no error, no panic — the logic is just silently wrong.
Mistake #4: building the URL by hand via string concatenation.
As long as values are “simple” (letters and digits only), everything looks fine. Then a space, &, +, #, % appears — and the request suddenly starts meaning something else. That’s why the query must be encoded via url.Values.Encode(), not via "?k=" + v.
Mistake #5: modifying q := u.Query() and forgetting to write it back to u.RawQuery.
This is a purely technical trap, but it comes up constantly. You changed q, printed u.String(), and it’s still the old value — and you start suspecting a net/url conspiracy. In reality, you must explicitly do u.RawQuery = q.Encode(). It’s not a whim, it’s part of the API contract: Query() gives you a convenient object to work with, but the final string is stored in RawQuery.
GO TO FULL VERSION