Go - On building URL strings
Building URL strings in Go may be accomplished in a couple different ways:
url.Parse- string concatenation (using
+) fmt.Sprintfbytes.Bufferstrings.Builder
You may be asking yourself: which one should I use, then? As always, the answer depends. Let us explore why.
URL.Parse
Before we start, a quick refresh on the URL structure:

One important fact to be aware is that when building URLs manually, it is easy to forget the percentage-encoding.
The url package implements the URL parsing and query encoding logic, although the api may be easy to misuse, like in the examples below.
Using url.Parse to parse a string:
package main
import (
"fmt"
"net/url"
)
func main() {
u, _ := url.Parse("https://example.org/resource?filter[page]=1")
// prints https://example.org/resource?filter[page]=1
fmt.Println(u)
}
url.Parse does not check if the query is properly encoded. However, it is possible to fix it by retrieving the query section and encoding it explicitly:
package main
import (
"fmt"
"net/url"
)
func main() {
u, _ := url.Parse("https://example.org/resource?filter[page]=1")
// prints https://example.org/resource?filter[page]=1
fmt.Println(u)
u.RawQuery = u.Query().Encode()
// prints https://example.org/resource?filter%5Bpage%5D=1
fmt.Println(u)
}
Another possible way to encode query string is using url.Values:
package main
import (
"fmt"
"net/url"
)
func main() {
u, _ := url.Parse("https://example.org/foo")
// prints https://example.org/foo
fmt.Println(u)
query := url.Values{}
query.Set("filter[page]", "1")
u.RawQuery = query.Encode()
// prints https://example.org/foo?filter%5Bpage%5D=1
fmt.Println(u)
}
On query building abstractions, the go-querystring package provides some extra functionality around url.Values, but follows the initial RawQuery usage.
package main
import (
"fmt"
"net/url"
"github.com/google/go-querystring/query"
)
type Options struct {
Page string `url:"filter[page]"`
}
func main() {
u, _ := url.Parse("https://example.org/foo")
// prints https://example.org/foo
fmt.Println(u)
q, _ := query.Values(Options{Page: "1"})
u.RawQuery = q.Encode()
// prints https://example.org/foo?filter%5Bpage%5D=1
fmt.Println(u)
}
Something to be aware with url.Parse is that http.NewRequestWithContext signature is:
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error)
URL argument type is string, but it will be parsed to url.URL:
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
// omitted code
// ...
u, err := urlpkg.Parse(url) //net/url package
// ...
When using http.NewRequestWithContext, regardless of the method selected to build the URL string, url.Parse is going to be called at least once.
Community Usage
It is always interesting to check how the community tackles this issue to understand if there is a clear preference.
go-github
https://github.com/google/go-github/blob/master/github/pulls.go#L165
func (s *PullRequestsService) ListPullRequestsWithCommit(ctx context.Context, owner, repo, sha string, opts *PullRequestListOptions) ([]*PullRequest, *Response, error) {
u := fmt.Sprintf("repos/%v/%v/commits/%v/pulls", owner, repo, sha)
u, err := addOptions(u, opts)
if err != nil {
return nil, nil, err
}
// ...
// https://github.com/google/go-github/blob/master/github/github.go#L242
func addOptions(s string, opts interface{}) (string, error) {
v := reflect.ValueOf(opts)
if v.Kind() == reflect.Ptr && v.IsNil() {
return s, nil
}
u, err := url.Parse(s)
if err != nil {
return s, err
}
qs, err := query.Values(opts)
if err != nil {
return s, err
}
u.RawQuery = qs.Encode()
return u.String(), nil
}
In this repo fmt.Sprintf is used in conjunction with go-querystring package.
consul client
https://github.com/hashicorp/consul/blob/api/v1.8.1/api/acl.go#L616
func (a *ACL) TokenClone(tokenID string, description string, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
if tokenID == "" {
return nil, nil, fmt.Errorf("Must specify a tokenID for Token Cloning")
}
r := a.c.newRequest("PUT", "/v1/acl/token/"+tokenID+"/clone")
String concatenation is used in this case.
godo
https://github.com/digitalocean/godo/blob/main/droplets.go#L322
// ListByTag lists all Droplets matched by a Tag.
func (s *DropletsServiceOp) ListByTag(ctx context.Context, tag string, opt *ListOptions) ([]Droplet, *Response, error) {
path := fmt.Sprintf("%s?tag_name=%s", dropletBasePath, tag)
In this repo fmt.Sprintf is used in conjunction with go-querystring package.
Benchmark
Looking at community usage, anecdotally fmt.Sprintf seems to be the preferred method.
Is there a numeric reason behind it? A simple way to test is benchmark URL building techniques and formats:
- All fields are strings: “https://example.com/resource/00000000-0000-0000-0000-000000000000"
- At least one field is an int which we need to convert: “https://example.com/resource/314159265"
Running with go test -bench=. -benchtime=10s
BenchmarkBytesString-24 91364389 131.1 ns/op 320 B/op 3 allocs/op
BenchmarkBytesStringAndItoa-24 100000000 100.8 ns/op 128 B/op 3 allocs/op
BenchmarkConcatString-24 247271304 48.57 ns/op 80 B/op 1 allocs/op
BenchmarkConcatStringAndItoa-24 150584938 79.36 ns/op 64 B/op 2 allocs/op
BenchmarkSprinfString-24 56899111 210.9 ns/op 128 B/op 4 allocs/op
BenchmarkSprinfDigit-24 54090478 226.2 ns/op 88 B/op 4 allocs/op
BenchmarkSprinfDigitItoa-24 46432692 256.5 ns/op 112 B/op 5 allocs/op
BenchmarkStringBuilderString-24 123711208 97.40 ns/op 168 B/op 3 allocs/op
BenchmarkStringBuilderStringAndItoa-24 123868472 96.78 ns/op 88 B/op 3 allocs/op
BenchmarkURLParseString-24 14717494 815.6 ns/op 256 B/op 4 allocs/op
BenchmarkURLParseResolveReference-24 5361052 2236 ns/op 1040 B/op 16 allocs/op
BenchmarkURLQueryEncode-24 9121402 1312 ns/op 536 B/op 11 allocs/op
BenchmarkURLQueryEncodePackageQueryString-24 6446503 1864 ns/op 984 B/op 17 allocs/op
Looking at benchmark results, string concatenation and strings.Builder seem the fastest and doing fewer allocations.
When not possible to ensure the URL will be correctly encoded, use url.Parse and call Encode explicitly.
While go-querystring usability may be convenient, it infers a performance cost price.
Benchmark code available at https://github.com/jacoelho/url_build_benchmark.