JavaRush /Courses /Go SELF /Reading query on the server — r.URL.Query()

Reading query on the server — r.URL.Query()

Go SELF
Level 57, Lesson 2
Available

1. Where the request URL lives in net/http

When you write a handler in Go for the first time, it feels like you were given two gifts: http.ResponseWriter and *http.Request. The first is for responding, the second is for understanding what you are responding to. And inside the second one, besides the method (GET/POST) and headers, there is also the request URL, already parsed into a struct. This is not magic; it is just a normal server interface. By the way, http.Request also carries a Context so a request can be cancelled properly, but in this lecture we will only note the fact and will not dive deeper.

The most minimal server “skeleton” looks roughly like this (similar to the classic net/http examples).


package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/api/v1/tasks", listTasksHandler)
	_ = http.ListenAndServe(":8080", nil)
}

func listTasksHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "ok") // ok
}

So far, the handler does not read anything. But even now we already have the key thing: r.URL is a *url.URL, and that is exactly where Path, RawQuery, and everything you need for query parameters live.

2. r.URL is already *url.URL, not “a string you have to parse”

A very common beginner mistake is to think that in an HTTP request the URL is stored only as a string, and that the server does not provide a “real struct”. In reality, net/http has already done part of the work for you: r.URL is a pointer to the url.URL struct. It stores the path and the query string separately, and even the fragment (although the fragment typically does not reach the server, because the browser does not send it — but that is just a detail of how the world works).

Let’s just look at what arrives in the handler without trying to “process” anything. This is useful for diagnostics: sometimes you think you have limit=10, but in reality the client sends Limit=10, and you spend an hour looking for “why it doesn’t work”. A programmer without diagnostics is like a cat without a box: technically alive, but unsettling.

package main

import (
	"fmt"
	"net/http"
)

func listTasksHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "path=%q\n", r.URL.Path)         // path="/api/v1/tasks"
	fmt.Fprintf(w, "rawQuery=%q\n", r.URL.RawQuery) // rawQuery="done=true&limit=10"
}

Note: RawQuery is stored without the ? character. That means it will be "a=b&c=d", not "?a=b&c=d".

3. The main button: r.URL.Query() → url.Values

This is the moment we came here for. In url.URL, the query string is stored as raw text (RawQuery), but working with it manually is inconvenient. That’s why url.URL has the Query() method, which returns url.Values — a structure designed specifically for query parameters.

It is important to internalize the idea: Query() turns “a string in the form a=b&c=d” into a proper data structure: “key → list of values”. This immediately explains why you should not design query as map[string]string: a single key can appear multiple times (tag=go&tag=http), and that is a normal, standard case.

Minimal reading example:

package main

import (
	"fmt"
	"net/http"
)

func listTasksHandler(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	fmt.Fprintln(w, "done:", q.Get("done"))   // done: true
	fmt.Fprintln(w, "limit:", q.Get("limit")) // limit: 10
}

Here Get("done") returns a string. Even if you expect a bool or an int, at the HTTP level it is a string. We will convert to bool/int later; today our goal is to confidently fetch the values.

4. Why url.Values stores []string and how Get differs from q[...]

It is normal if url.Values initially causes a bit of pain: “Why is it []string when I just want a single value?!”. But this is exactly the kind of case where the standard library is not messing with you — it is preventing future pain.

Consider the classic list of tags: /api/v1/tasks?tag=go&tag=http&tag=cli. This is not exotic; it is a common way to pass a list of values in query. That’s why url.Values stores all values for a key.

Let’s compare two ways of reading:

What we read How we read it What we get When it’s convenient
“One value” (the first)
q.Get("limit")
string (or "" if the key is missing) for single parameters (limit, sort, q)
“All values”
q["tag"]
[]string (or nil if the key is missing) for lists (tag=...&tag=...)

Let’s show it in code:

package main

import (
	"fmt"
	"net/http"
)

func listTasksHandler(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	fmt.Fprintln(w, "first tag:", q.Get("tag")) // first tag: go
	fmt.Fprintln(w, "all tags:", q["tag"])      // all tags: [go http cli]
}

Get takes the first value. Sometimes that is exactly what you need. But if the contract says the parameter can repeat, you should read it as a list.

5. Mini practice: read filters, search, and tags

Let’s build a handler that “as if” prepares for task filtering, but does not filter yet. It just reads the parameters and returns them back in the response. It looks toy-like, but in reality it is a great way to verify the contract: in the browser/curl you change the query and immediately see what the server understood.

We will stick to our resource /api/v1/tasks and typical parameters: done, q, tag, limit, offset, sort, order.

package main

import (
	"fmt"
	"net/http"
)

func listTasksHandler(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	fmt.Fprintln(w, "done =", q.Get("done"))
	fmt.Fprintln(w, "q    =", q.Get("q"))
	fmt.Fprintln(w, "tags =", q["tag"])
}

A mental check looks like this:

  • /api/v1/tasks?done=true&q=milk&tag=go&tag=http
  • the server will respond:
    • done = true
    • q = milk
    • tags = [go http]

So far it’s all strings and slices of strings. That is normal. Today we are not building a “smart filter”; today we are learning how to reliably and predictably extract input data.

6. Nuance: “key is missing” and “value is empty” are not the same thing

q.Get("k") returns an empty string both when the key does not exist, and when the key does exist but the value is empty (k=). Sometimes this does not matter, but sometimes it is critical for contracts and defaults.

For example, q= may mean “the user explicitly wants to search for an empty string” (even though that is strange), while a missing q means “no search, return everything”. To distinguish these cases, you can check whether the key exists via direct access to the map.

package main

import (
	"fmt"
	"net/http"
)

func listTasksHandler(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	_, hasQ := q["q"]
	fmt.Fprintln(w, "q present:", hasQ) // q present: true/false
	fmt.Fprintln(w, "q value:", q.Get("q"))
}

If the client sends /api/v1/tasks?q=, then hasQ will be true, and q.Get("q") will return "".

This is not a “trick for the sake of a trick”. It is a normal foundation for defaults and validation: a default is usually applied when a parameter is missing, not when it is present but empty.

7. Useful nuances and tools around query

url.ParseQuery: when you are not in a handler, but you still need query logic

Sometimes you have a query string “as text”, but you are not inside an HTTP handler. For example, you are writing tests for your parameter-parsing function, or you are building a utility that accepts the string a=b&c=d and converts it into url.Values.

For such cases, there is url.ParseQuery. The logic is the same as r.URL.Query(), just the input is a plain string.

package main

import (
	"fmt"
	"net/url"
)

func main() {
	q, _ := url.ParseQuery("tag=go&tag=http&q=milk")

	fmt.Println("q:", q.Get("q"))  // q: milk
	fmt.Println("tags:", q["tag"]) // tags: [go http]
}

This is convenient for testing: you do not need to start a server; you can verify that parameter parsing behaves as expected.

Nuance: Query() returns a copy (and that’s good)

There is a subtle point that comes up in real code more often than it seems. When you do: q := r.URL.Query() you get url.Values. Formally, it is a map, and you can even do q.Set(...). But in the context of an incoming server request, this is almost never needed, and if you do it, the changes should not be perceived as “I changed the request URL”.

In our server scenario the rule is simple: query parameters are input, not a place for mutations. We read them, validate them, apply them to the logic — and that’s it. If you start “fixing the request” on the fly, you will quickly end up with code where it is hard to tell what came from the client and what you invented yourself.

If someday you need to build a URL (for example, for a client or a redirect) — that is done via url.URL + url.Values.Encode(), but that is a different story and a different context.

Diagram: what happens when the server reads query

To lock in the mental model, here is a small diagram of “how the handler thinks”. There is no magic here — only neat steps that are easy to repeat every time.

flowchart TD
    A[HTTP request: /api/v1/tasks?tag=go&tag=http&done=true] --> B[net/http creates *http.Request]
    B --> C[r.URL: *url.URL]
    C --> D["r.URL.Query(): url.Values"]
    D --> E["Read q.Get / q[...]"]
    E --> F[Next: validation and applying to logic]

In today’s lecture, we confidently get to the step “Read q.Get / q[...]”. The next step (validation and defaults) we intentionally do not dive into yet, so we do not mix “how to extract” and “how to properly check”.

8. Typical mistakes when reading query on the server

Mistake #1: parsing query manually via strings.Split and “it kinda works”.
This usually ends with weird bugs around escaping, plus signs instead of spaces, repeated keys, and “why do I only have one tag”. r.URL.Query() solves this in a standard, reliable way: you get url.Values and work with it as data, not as a string.

Mistake #2: reading lists via Get, then being surprised there are fewer values.
Get("tag") returns only the first value. If your contract is tag=go&tag=http, you must read q["tag"]. Otherwise you politely “lose the user’s data”, and then pretend it was intended.

Mistake #3: assuming Get("k") == "" means “the key is missing”.
This is incorrect: k= and a missing k look the same to Get. If it matters to distinguish them, check key presence via _, ok := q["k"]. Otherwise defaults and errors will be applied “to the wrong thing”, and users will start finding philosophical bugs in your API.

Mistake #4: mixing query reading and business logic into one giant blob.
When a handler reads parameters, parses numbers, checks ranges, applies filters, and builds a response all at once, the code becomes heavy even for the author a week later. It is much clearer to first “lift” strings out of url.Values, and then, as a separate step, type and validate them, returning clear errors.

Mistake #5: trying to “fix” the incoming request by mutating q as a map.
On the server, the request is input data. If you start mutating query, you erase the boundary between “came from outside” and “we invented it ourselves”. This makes diagnostics worse and can lead to unexpected effects. The correct approach: read parameters, then work with your own variables/structs.

1
Task
Go SELF, level 57, lesson 2
Locked
Echo parameters
Echo parameters
1
Task
Go SELF, level 57, lesson 2
Locked
Tag Registry
Tag Registry
1
Task
Go SELF, level 57, lesson 2
Locked
Query presence
Query presence
1
Task
Go SELF, level 57, lesson 2
Locked
Parameter dump
Parameter dump
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION