Skip to content
CalliCoder

Golang URL Encoding and Decoding

Golang 12 min read

QueryEscape turns a space into a plus and PathEscape turns it into %20, url.Values handles repeated keys, and building a URL by concatenating strings is how the encoding gets skipped.

Go has two escaping functions that differ in one visible way and several invisible ones. Using QueryEscape on a path segment produces a URL that resolves to the wrong resource, and the failure is a + where a space should be, small enough to survive review.

Written against Go 1.22.

Query escaping and path escaping

package main

import (
	"fmt"
	"net/url"
)

func main() {
	s := "hello world/foo?bar"

	fmt.Println(url.QueryEscape(s)) // hello+world%2Ffoo%3Fbar
	fmt.Println(url.PathEscape(s))  // hello%20world%2Ffoo%3Fbar
}

The space is the visible difference. QueryEscape encodes it as +, which is the application/x-www-form-urlencoded convention and is only valid in a query string or a form body. PathEscape encodes it as %20, which is correct everywhere.

The invisible differences are in which other characters each leaves alone. PathEscape permits $, &, +, ,, :, ;, =, @, all legal in a path segment, while QueryEscape escapes them, because in a query string & and = are separators.

Put a + in a path with QueryEscape and the server sees a literal plus. Put a space in a path with QueryEscape and the server sees a plus too, which is a different path.

The decoders mirror them:

q, err := url.QueryUnescape("hello+world%2Ffoo")
// "hello world/foo"

p, err := url.PathUnescape("hello%20world%2Ffoo")
// "hello world/foo"

QueryUnescape turns + into a space; PathUnescape leaves it as a plus. Decoding a path with QueryUnescape corrupts any filename containing a +.

Both return an error for a malformed escape (%zz, or a % at the end of the string) so the error is worth checking rather than discarding.

There is a third pair, url.PathUnescape’s counterpart for a whole path rather than a segment. The distinction matters because a path is several segments joined by slashes, and escaping the joined string escapes the separators too:

segment := "reports/2026"
fmt.Println(url.PathEscape(segment))   // reports%2F2026 — one segment named "reports/2026"

That is correct if the name genuinely contains a slash, and wrong if it was meant as two segments. Escape each segment and join with /, or use JoinPath, which does exactly that.

Building a query string

Do not concatenate. url.Values is a map[string][]string with an encoder:

values := url.Values{}
values.Set("q", "go url encoding")
values.Set("page", "2")
values.Add("tag", "http")
values.Add("tag", "stdlib")

fmt.Println(values.Encode())
// page=2&q=go+url+encoding&tag=http&tag=stdlib

Set replaces, Add appends. Repeated keys are the reason the value type is a slice, a query string can legitimately carry tag twice, and a plain map cannot represent that.

Encode sorts by key. That is deliberate: the output is deterministic, which matters for cache keys, request signatures and tests. It also means the order you added parameters in is not preserved, and for the handful of APIs that require a specific parameter order, url.Values is the wrong tool.

Parsing

u, err := url.Parse("https://example.com/search?q=go+url&page=2#results")
if err != nil {
	log.Fatal(err)
}

fmt.Println(u.Scheme)   // https
fmt.Println(u.Host)     // example.com
fmt.Println(u.Path)     // /search        — decoded
fmt.Println(u.RawQuery) // q=go+url&page=2 — NOT decoded
fmt.Println(u.Fragment) // results

u.Path is decoded and u.RawPath holds the original when the two differ. u.RawQuery is the raw text; parsing it is a separate step:

params := u.Query()          // parses RawQuery into url.Values
fmt.Println(params.Get("q")) // go url

Query() re-parses on every call, so calling it in a loop is a repeated parse. Assign it once.

Get returns the first value and an empty string when the key is absent. There is no comma-ok form. To distinguish “absent” from “present and empty”:

if vals, ok := params["q"]; ok && len(vals) > 0 {
	// present
}

That distinction matters for a flag-style parameter such as ?debug, where presence is the signal.

url.Parse is lenient by design: it accepts relative references and unusual input. url.ParseRequestURI requires an absolute URL and rejects a fragment, which is the stricter check for input arriving from outside.

Assembling a URL properly

u := &url.URL{
	Scheme: "https",
	Host:   "example.com",
	Path:   "/articles/hello world",   // set unencoded — String() escapes it
}

q := u.Query()
q.Set("ref", "newsletter&promo")
u.RawQuery = q.Encode()

fmt.Println(u.String())
// https://example.com/articles/hello%20world?ref=newsletter%26promo

Setting Path with the decoded value and letting String() escape it is the correct direction. Pre-escaping the value and assigning it to Path produces double encoding, %20 becomes %2520.

u.JoinPath("segment") appends and escapes correctly, which is the safe way to build a path from parts. Concatenating with + and a / is where a segment containing a slash silently becomes two segments: a path-traversal bug when the segment came from a user.

Encoding a form body

form := url.Values{}
form.Set("username", "[email protected]")
form.Set("password", "p@ss word&")

resp, err := http.Post(
	"https://example.com/login",
	"application/x-www-form-urlencoded",
	strings.NewReader(form.Encode()),
)

Same Encode, in the body rather than the query. The content type is what tells the server to decode + as a space, and omitting it is why a form post arrives with plus signs in the values.

http.PostForm wraps those three arguments and is shorter. Neither sets a timeout, so production code wants an http.Client with one rather than the package-level helpers, which use http.DefaultClient and wait indefinitely.

Reading parameters in an HTTP handler

On the server side the same types appear, with one extra step that is easy to skip:

func handler(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	search := q.Get("q")

	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad form", http.StatusBadRequest)
		return
	}
	username := r.PostForm.Get("username")   // body only
	either := r.Form.Get("username")         // body and query merged
}

r.URL.Query() reads the query string and nothing else. r.PostForm is populated only after ParseForm has run, and reading it before that returns empty values with no error: the most common reason a form handler sees blank fields.

r.Form merges query and body, with body values first. That is convenient and occasionally a problem: a parameter an attacker cannot control in the body may be controllable in the query, so a handler making a security decision should read from the specific source rather than the merged one.

ParseForm also enforces a size limit on the body, defaulting to 10 MB, which is the only thing standing between a handler and an unbounded read. ParseMultipartForm takes the limit as an argument for file uploads.

One detail worth knowing: an invalid percent escape anywhere in the query makes ParseForm return an error, but the successfully parsed pairs are still available. Treating the error as fatal is the safe default; treating it as a warning and using the partial result is a decision to make deliberately.

What is not escaped for you

url.Values.Encode and url.URL.String produce a valid URL. Neither makes the content safe for wherever it is rendered:

  • Putting a URL into HTML needs HTML escaping as well, html/template does it.
  • A URL from user input can carry a javascript: scheme, which no percent-encoding prevents. Check u.Scheme against a list.
  • A redirect target from a query parameter needs its host checked, or it is an open redirect.

Percent-encoding answers “is this a valid URL”, not “is this URL safe to use”. The two questions get conflated because the same function appears in both answers, and only one of them is the encoder’s job.

More Go walkthroughs in the Golang guides, including base64 encoding for the other common encoding question.

Frequently asked questions

What is the difference between QueryEscape and PathEscape?

QueryEscape encodes a space as + and escapes the query separators; PathEscape encodes a space as %20 and permits characters that are legal in a path segment. Using the query form on a path changes the resource being addressed.

Why does my URL contain a plus instead of a space?

QueryEscape was used where PathEscape was meant. + for a space is only valid in a query string or a form body.

How do I build a query string with repeated keys?

url.Values holds a slice per key. Add appends, Set replaces, and Encode writes each value as its own key=value pair.

Why is Encode() sorting my parameters?

By design, so the output is deterministic for caching, signing and tests. If parameter order is significant, build the string yourself.

How do I get a query parameter?

u.Query().Get("name"). Assign u.Query() to a variable first — it re-parses RawQuery on every call.

How do I tell an absent parameter from an empty one?

Get returns "" for both. Index the map directly and check the ok value plus the slice length.

Why did my path get double-encoded?

The value was escaped before being assigned to url.URL.Path, and String() escaped it again. Assign the decoded value and let String() do the encoding.

How do I append a path segment safely?

u.JoinPath("segment"). String concatenation lets a segment containing / become two segments, which is a traversal bug on user input.

url.Parse or url.ParseRequestURI?

Parse is lenient and accepts relative references. ParseRequestURI requires an absolute URL and rejects fragments, the stricter choice for untrusted input.

Does escaping make a URL safe?

No. It makes it valid. A javascript: scheme, an open redirect and HTML context all need separate checks.