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/templatedoes it. - A URL from user input can carry a
javascript:scheme, which no percent-encoding prevents. Checku.Schemeagainst 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.