When dealing with HTTP based API’s it’s common to pass data using URL path parameters (also called path variables). These parameters are part of the path segment of an URL. They are usually used to identify resources for API operations.
In all but the simplest web apps, API’s are defined using routes. Patterns which map requests to HTTP handlers.
As part of these routes, we may want to define path parameters:
/products/{slug}
/users/{id}/profile
/{page}
In the above routes, the {slug}
, {id}
and {page}
are named path parameters.
The idea is that these parameters can then be retrieved and used inside the HTTP handlers by their names.
func handler(w http.ResponseWriter, r *http.Request) {
// Get slug, id or page from in here.
}
Now, before Go version 1.22 named parameters like seen above were not supported by the standard library. This made path parameters a bit of a pain to work with.
Inside HTTP handlers you would need to split the URL path into different parts, you'd then use indices to identify the variable parts.
This directly couples your HTTP handlers to the number of parts in the URL path. Named path parameters don't have this problem.
My read on the situation is that most developers used third-party routers that supported proper named parameters in routes to sidestep this issue.
However, arriving in Go 1.22 are enhanced routing patterns, which includes full support for named path parameters.
Let’s see how to use them.
Defining routes
There are two methods on the http.ServeMux
type that allow you to define routes with these routing patterns: Handle
and HandleFunc
.
They only differ in the type of handlers they accept. One accepts http.Handler
and the other accepts functions with the following signature:
func(w http.ResponseWriter, r *http.Request)
In this article we’ll always use http.HandleFunc
because it’s a bit more concise.
Wildcards
If you check the documentation there is no mention of “path parameters”, but there is a slightly broader concept: Wildcards.
Wildcards allow you to define variable parts of an URL path in a number of different ways. So how are they defined?
Wildcards must be full path segments: they must be preceded by a slash and followed by either a slash or the end of the string.
For example, the following three route patterns contain valid wildcards:
/{message}
/products/{slug}
/{id}/elements
Note that the wildcards must be full path segments. Partial path segments are invalid:
/product_{id}
/articles/{slug}.html
Getting the values
The concrete values provided for a wildcard can be retrieved using the PathValue
method on the *http.Request
type.
You pass this method the name of the wildcard and it will return its value as a string
, or the empty string ""
when no value is present.
You’ll see PathValue
in action in the examples below.
We'll generally just print the value directly to stdout. In real apps you will want to validate or parse this value because it's untrusted user input.
Basic example
Below we create a /greetings/{greeting}
endpoint. The HTTP handler will take the value for the wildcard and print it to stdout.
In the example we send 6 requests, if a request fails we will print an error message with the url and status code.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := &http.ServeMux{}
// set up the endpoint with a "greeting" wildcard.
mux.HandleFunc("/greetings/{greeting}", handler)
urls := []string{
"/greetings/hello-world",
"/greetings/good-morning",
"/greetings/hello-world/extra",
"/greetings/",
"/greetings",
"/messages/hello-world",
}
for _, u := range urls {
req := httptest.NewRequest(http.MethodGet, u, nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
resp := rr.Result()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Request failed: %d %v\n", resp.StatusCode, u)
}
}
}
func handler(w http.ResponseWriter, r *http.Request) {
// get the value for the greeting wildcard.
g := r.PathValue("greeting")
fmt.Printf("Greeting received: %v\n", g)
}
If you run the example you will see that the last 4 requests don’t get routed to the handler.
/greetings/hello-world/extra
does not match, because of an additional path segment that is not in the routing pattern./greetings
and/greetings/
because they are missing a path segment./messages/hello-world
because the first path segments don’t match.
Multiple wildcards
It’s possible to specify multiple wildcards in a single pattern. Below we use two wildcards in a /chats/{id}/message/{index}
endpoint.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := &http.ServeMux{}
// set up the endpoint with a "time" and "greeting" wildcard.
mux.HandleFunc("/chats/{id}/message/{index}", handler)
urls := []string{
"/chats/102/message/31",
"/chats/103/message/1",
"/chats/104/message/4/extra",
"/chats/105/",
"/chats/105",
"/chats/",
"/chats",
"/messages/hello-world",
}
for _, u := range urls {
req := httptest.NewRequest(http.MethodGet, u, nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
resp := rr.Result()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Request failed: %d %v\n", resp.StatusCode, u)
}
}
}
func handler(w http.ResponseWriter, r *http.Request) {
// get the value for the id and index wildcards.
id := r.PathValue("id")
index := r.PathValue("index")
fmt.Printf("ID and Index received: %v %v\n", id, index)
}
Just like in the previous example, each wildcard segment is required to have a value.
Matching remainder
The last wildcard in a pattern can optionally match all remaining path segments by having its name end in ...
In the example below we use such a pattern to pass “steps” to a /tree/
endpoint.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := &http.ServeMux{}
// set up the endpoint with a "steps" wildcard.
mux.HandleFunc("/tree/{steps...}", handler)
urls := []string{
"/tree/1",
"/tree/1/2",
"/tree/1/2/test",
"/tree/",
"/tree",
"/none",
}
for _, u := range urls {
req := httptest.NewRequest(http.MethodGet, u, nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
resp := rr.Result()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Request failed: %d %v\n", resp.StatusCode, u)
}
}
}
func handler(w http.ResponseWriter, r *http.Request) {
// get the value for the steps wildcard.
g := r.PathValue("steps")
fmt.Printf("Steps received: %v\n", g)
}
As expected, the first 3 requests get routed to handler with all the remaining steps as a value.
In contrast to our earlier example, /tree/
also matches the pattern with an empty value for "steps"
. An “empty remainder” counts as a remainder.
/tree
and /none
still don’t match out routing pattern.
/tree
now results in a 301 Redirect and not a 404 Not Found Error.
This is because of trailing slash redirection behaviour on the http.ServeMux
. It's unrelated to the wildcards we're discussing.
Pattern with trailing slash
If a routing pattern ends in a trailing slash, that will result in an “anonymous” "..."
wildcard.
This means that you can’t get a value for this wildcard, but it will still match the route as if the last path segment was a “remainder matching segment”.
If we apply this to our previous tree example, we get the following /tree/
endpoint:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := &http.ServeMux{}
// set up the endpoint with a trailing slash:
mux.HandleFunc("/tree/", handler)
urls := []string{
"/tree/1",
"/tree/1/2",
"/tree/1/2/test",
"/tree/",
"/tree",
"/none",
}
for _, u := range urls {
req := httptest.NewRequest(http.MethodGet, u, nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
resp := rr.Result()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Request failed: %d %v\n", resp.StatusCode, u)
}
}
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("URL Path received: %s\n", r.URL.Path)
}
Note that we can’t retrieve the steps using r.PathValue
, so we use r.URL.Path
instead.
However, the matching rules are identical to our previous example.
Match end of URL
To match the end of the URL the special wildcard {$}
can be used.
This is useful when you don’t want a trailing slash to result in an anonymous "..."
wildcard, but just match on the trailing slash.
For example, if modify our tree endpoint to use /tree/{$}
, it will now only match /tree/
requests:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := &http.ServeMux{}
// set up the endpoint with the match end wildcard:
mux.HandleFunc("/tree/{$}", handler)
urls := []string{
"/tree/",
"/tree",
"/tree/1",
"/none",
}
for _, u := range urls {
req := httptest.NewRequest(http.MethodGet, u, nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
resp := rr.Result()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Request failed: %d %v\n", resp.StatusCode, u)
}
}
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("URL Path received: %s\n", r.URL.Path)
}
Another case where this is useful is when dealing with “homepage” requests.
The /
pattern matches requests to all URLs, but the /{$}
pattern only matches requests to /
.
Setting path values
In tests or middleware it can be desired to set path values on a request.
This can be done using the SetPathValue
method on *http.Request
. It accepts a key/value pair and subsequent calls to PathValue
will return the set value for that key.
See an example below.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
// set path value before passing request to a handler.
req.SetPathValue("greeting", "hello world")
handler(rr, req)
}
func handler(w http.ResponseWriter, r *http.Request) {
g := r.PathValue("greeting")
fmt.Printf("Received greeting: %v\n", g)
}
Route matching and precedence
It’s possible to have multiple routes that could potentially match a request.
For example, take the following two routes:
/products/{id}
/products/my-custom-product
When a request is received for the URL /products/my-custom-product
, both routes could potentially match it.
So which one is actually matched?
In this case, the last route, /products/my-custom-product
. Because it is more specific. It matches less requests than the first route.
Note that the order does not matter, eventhough /products/{id}
is defined first it’s not matched.
The example below demonstrates this.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := &http.ServeMux{}
// set up two endpoints
mux.HandleFunc("/products/{id}", idHandler)
mux.HandleFunc("/products/my-custom-product", customHandler)
urls := []string{
"/products/test",
"/products/my-custom-product",
}
for _, u := range urls {
req := httptest.NewRequest(http.MethodGet, u, nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
resp := rr.Result()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Request failed: %d %v\n", resp.StatusCode, u)
}
}
}
func idHandler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s -> idHandler\n", r.URL.Path)
}
func customHandler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s -> customHandler\n", r.URL.Path)
}
Conflicts
Registering routes that have the same specificity and that match the same requests will lead to a conflict. When registering such a route, the the Handler
and HandleFunc
methods will panic.
To trigger such a panic in the previous example, change the registration of the customHandler
from:
// ...
mux.HandleFunc("/products/my-custom-product", customHandler)
// ...
To:
mux.HandleFunc("/products/{name}", customHandler)
If you then run the program you will get a panic:
panic: pattern "/products/{name}" ... conflicts with pattern "/products/{id}" ...: /products/{name} matches the same requests as /products/{id}
Summary
This article discussed the implementation of URL path parameters using the wildcard routing patterns introduced in Go 1.22.
The key takeways are:
- Wildcards can be used to create one or many path parameters in a route.
- Get path values using the
PathValue
method. - Use a remainder matching wildcard to match trailing path segments.
- A trailing slash acts as a remainder matching wildcard.
- Use the
{$}
wildcard to disable this behavior. - Set path values on a request using
SetPathValue
. - Routes are matched based on specificity.
- Registering routes can panic.
That’s it for today. I hope you learned something :)
Get my free newsletter every second week
Used by 500+ developers to boost their Go skills.
"I'll share tips, interesting links and new content. You'll also get a brief guide to time for developers for free."