URL path parameters in routes

Avatar of the author Willem Schots
15 Feb, 2024
~9 min.
RSS

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.

Location and monotonic timelines of a time.Time value

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.

main.go
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.

main.go
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.

main.go
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.

Note though, the request for /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:

main.go
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:

main.go
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.

main.go
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.

main.go
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."

Avatar of the author
Willem Schots

Hello! I'm the Willem behind willem.dev

I created this website to help new Go developers, I hope it brings you some value! :)

You can follow me on Bluesky, Twitter/X or LinkedIn.

Thanks for reading!