1. Why id in the path is the language of an API
When you first see a URL like /tasks/42, it can be tempting to think: “Why make it so complicated—couldn’t we just write /tasks?id=42?” You can. But an API has its own “language”, and the path in a URL is usually about the resource, while query parameters are about request options. It’s like a delivery address: street and building are “where”, and a note to the courier is “how exactly”.
If we have a list of tasks, then /api/v1/tasks reads like “a collection of tasks”. And /api/v1/tasks/42 reads like “a specific task with identifier 42”. This helps keep a clear API structure where the path answers “what exactly are we touching”, instead of turning into a messy pile of parameters.
The main practical reason (no philosophy): this style is easier to read, easier to document, and easier to extend. And it maps perfectly to routing: we can tell the router “right here there is {id}; substitute any single path segment there—and pass it to the handler”.
2. The {id} path parameter in ServeMux: how it matches
When we say “path parameter”, we mean a variable part of the path that occupies exactly one segment between slashes. This matters: {id} is not “any string of any length”, but specifically “whatever is between / and the next /”. That’s why /tasks/{id} matches /tasks/42, but it doesn’t become a vacuum cleaner that sucks in half the internet.
Starting with Go 1.22, net/http.ServeMux patterns support methods, wildcards, and capturing segments like {id}; and the captured value can be retrieved from *http.Request. This means we don’t need to manually parse r.URL.Path, do strings.Split, catch off-by-one errors, and then explain to ourselves why production suddenly received a path with a double slash.
A minimal route registration looks like this:
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks/{id}", handleGetTask)
_ = http.ListenAndServe(":8080", mux)
}
func handleGetTask(w http.ResponseWriter, r *http.Request) {
// Empty for now — in the next sections we’ll learn how to extract id.
}
Notice two things. First, {id} is the parameter name. Second, the parameter name is part of the contract: if you wrote {id}, then you must extract exactly "id", not "taskId" and not "ID".
3. Extraction and parameter names
Extracting the value: r.PathValue("id")
Now for the best part: how to get what actually arrived in the URL.
r.PathValue("id") returns a string—that is, the “raw” value of the path segment. At this stage it is not yet a number, not a UUID, and not a “definitely valid value”. It’s just text from the URL. And that’s good: extraction and validation are different tasks. Today we honestly do only extraction (and we’ll centralize validation and conversion to a number separately, so we don’t multiply copy-paste).
Here is a minimal handler example that shows what exactly came in:
package main
import (
"fmt"
"net/http"
)
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "task id = %q\n", id) // task id = "42"
}
Here %q is useful because it prints the string in quotes. If id is empty, you’ll see it as "" instead of guessing “did it print or not?”
What PathValue returns if the parameter is missing
Usually, in a correctly registered route the parameter will be there. But if you made a mistake in the name (for example, the pattern has {taskID}, but you read "id"), PathValue will return an empty string. At this point many beginners start “treating the symptoms” (adding checks), while the right fix is to align the parameter names so they match.
Case matters: the name in {...} and the key in PathValue(...) must match
In programming, half of the bugs are “we agreed, but forgot what we agreed on”. With path parameters this shows up especially clearly: the parameter name in {...} and the string passed to PathValue(...) must match character for character, including case.
Imagine {id} is a sticker on a box. If you later search for the box using the sticker "ID", but the sticker was "id", then formally you are “searching for something else”. Go doesn’t owe you anything—it will just return an empty string.
An example of a correct match:
mux.HandleFunc("GET /api/v1/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
_, _ = w.Write([]byte(id + "\n"))
})
And here is a very real-life mistake:
mux.HandleFunc("GET /api/v1/tasks/{taskID}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // error: the name doesn't match, id will be ""
_, _ = w.Write([]byte(id + "\n"))
})
How to name it better: {id} or {taskID}
If your route has exactly one identifier and it’s clear from context whose it is, {id} is fine. If there are multiple parameters (for example, a user and a task), then it’s better to be more specific: {userID} and {taskID}. This improves code readability without any comments.
4. Multiple parameters and query parameters
Multiple path parameters: example /users/{userID}/tasks/{taskID}
When an API starts to live (meaning you stop writing “Hello world” and start writing “this looks like a real server”), nested resources show up very quickly: a user’s tasks, a task’s comments, a comment’s attachments, and so on. And this is exactly where path parameters start saving real time and nerves.
Let’s look at an example route with two parameters:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/users/{userID}/tasks/{taskID}", func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userID")
taskID := r.PathValue("taskID")
fmt.Fprintf(w, "user=%s task=%s\n", userID, taskID) // user=7 task=42
})
_ = http.ListenAndServe(":8080", mux)
}
Notice: we do not manually parse r.URL.Path. We do not count slashes. We do not argue with ourselves at 3 a.m. about “why is the index 3 and not 2”. We just ask the request: “give me "userID"” and “give me "taskID"”.
If you’ve ever split paths by hand using strings.Split, you should feel a bit of joy right now. If you don’t—no worries, you will after your first production bug.
Path vs query parameters: what’s the difference
A very common beginner confusion: “So {id} is the same thing as ?id=..., right?” No. They are similar in that both give you a string, but their meaning is usually different.
Let’s compare in practice:
| What we want to express | Example URL | Where the value lives in Go |
|---|---|---|
| “a specific resource” | |
|
| “a request option” | |
|
| “a filter/output mode” | |
|
A small example: let’s say we have a task endpoint by id, and we added a “verbosity mode” via the query parameter verbose (purely to demonstrate the difference):
package main
import (
"fmt"
"net/http"
)
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
verbose := r.URL.Query().Get("verbose")
fmt.Fprintf(w, "id=%s verbose=%s\n", id, verbose) // id=42 verbose=1
}
In this example, id is part of the path because it answers “which task are we reading”, while verbose is a query parameter because it answers “how exactly should we show the response”.
5. Integrating into the tutorial app
Now we’ll take a small but important step: we’ll add a handler that extracts {id} and returns a JSON response. We won’t convert id to an int yet, and we won’t prove that it’s “definitely correct”—our goal today is to learn to extract the value neatly, without manual path parsing.
Assume we already have writeJSON from previous lectures (it sets Content-Type, writes the status, and encodes a struct into JSON). Then the handler might look like this:
package main
import (
"net/http"
)
type taskResponse struct {
ID string `json:"id"`
}
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
writeJSON(w, http.StatusOK, taskResponse{ID: id})
}
If you really want to “see it” without a JSON helper, you can temporarily do this:
package main
import (
"fmt"
"net/http"
)
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, `{"id":%q}`+"\n", id) // {"id":"42"}
}
This version isn’t ideal (hand-building JSON is like fixing a watch with a hammer: it sometimes works, but it’s better not to get used to it), but it clearly shows that id is a string and we actually received it.
A small safety net: checking for an empty string
Sometimes it’s useful to add a tiny helper that turns “empty string” into an explicit condition. One important note right away: if your route is registered correctly and the parameter name matches, id should not be empty. So this check is not “user validation”, but rather “protection against our own typos and refactoring”.
For example:
package main
import "net/http"
func mustPathValue(r *http.Request, key string) (string, bool) {
v := r.PathValue(key)
return v, v != ""
}
Usage:
package main
import "net/http"
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id, ok := mustPathValue(r, "id")
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = id // use id further
w.WriteHeader(http.StatusOK)
}
Why 500 here instead of 400? Because if id is not found, most often it’s not the user’s fault—it’s ours: the route and the code disagree on the names. The user could send any URL, but if the handler was invoked, it means ServeMux decided the route matched. And if it matched but the parameter can’t be extracted, that looks very much like a bug in the code.
6. Common mistakes when working with {id} and r.PathValue(...)
Error #1: the name in {...} does not match the key in PathValue(...).
The most common bug is a typo or a different naming style: {taskID} in the route and r.PathValue("taskId") in the code. As a result, PathValue returns an empty string, and then you spend an hour searching for “why did id disappear”. The cure is discipline: one style and attention to case.
Error #2: trying to get a path parameter via r.URL.Query().
Query() works only with the part after ?. If you write /tasks/42, then 42 is not query—it’s a path segment. So r.URL.Query().Get("id") will return empty, and that’s normal. Use r.PathValue("id") for {id}, and Query() for ?x=y.
Error #3: manually parsing r.URL.Path instead of using PathValue.
You can do strings.Split(r.URL.Path, "/"), then take “the third element” and hope for the best. But it’s fragile: an extra slash, a different prefix, an API version, and everything breaks. The built-in mechanism {id} + PathValue is designed precisely so you don’t have to play “guess the index”.
Error #4: mixing extraction and conversion “in one line”.
Sometimes people write: strconv.Atoi(r.PathValue("id")) and are happy—until "abc" arrives and turns into “0, but it seems to work”. As a habit, it’s better to separate the steps: first extract the string, then parse and validate it separately (and centrally).
Error #5: using the same {id} for different entities within the same path.
The route /users/{id}/tasks/{id} may look “logical” technically, but in code it turns into mush: which id are you reading now? Better to be explicit: {userID} and {taskID}. This is a case where two extra letters save you dozens of minutes of debugging.
GO TO FULL VERSION