Building URL strings in Go may be accomplished in a couple different ways:

  • url.Parse
  • string concatenation (using +)
  • fmt.Sprintf
  • bytes.Buffer
  • strings.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:

url

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:

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.