Percent-Encoding Explained: How URL Encoding Works, Byte by Byte, per RFC 3986

A URL may only contain a small set of ASCII characters, and some of those have a structural meaning: `/` separates path segments, `?` starts the query, `&` separates parameters. Percent-encoding is how everything else gets in. Each byte that cannot appear literally is written as `%` followed by its value in hex. The rule fits on one line; knowing which characters need it in which part of the URL is the harder part.

The mechanism: bytes, not characters

Percent-encoding works on bytes. A character is first converted to bytes using UTF-8, which RFC 3986 recommends and the WHATWG URL Standard requires, and then each byte that needs encoding becomes %XX.

character   UTF-8 bytes     percent-encoded
space       20              %20
/           2F              %2F
é           C3 A9           %C3%A9
€           E2 82 AC        %E2%82%AC
😀          F0 9F 98 80     %F0%9F%98%80

That is why one visible character can become up to twelve characters in a URL, and why %E9 on its own, the Latin-1 byte for é, is not valid in a modern URL: it is not a complete UTF-8 sequence. Decoders reject it or replace it with �.

Hex digits are case-insensitive: %c3%a9 and %C3%A9 mean the same bytes. RFC 3986 recommends upper case, and URL normalisers convert to it, so emit upper case to keep URLs comparable.

Unreserved, reserved and everything else

RFC 3986 divides characters into three groups, and the group decides whether a character may appear literally.

unreserved   A-Z a-z 0-9 - . _ ~          never need encoding
reserved     gen-delims : / ? # [ ] @     have a structural meaning
             sub-delims ! $ & ' ( ) * + , ; =
other        space " < > \ ^ ` { | } %,   always encoded
             control chars, all non-ASCII

Unreserved characters are always safe, and encoding them changes nothing: %41 and A are equivalent, and normalisers decode them. Reserved characters are allowed literally where they play their structural role, and must be encoded when they are part of data. A / inside a filename used as a path segment must be %2F, or the server sees two segments.

The % sign itself is always encoded, as %25. Forgetting this is what makes 100% invalid and double encoding possible.

What to encode in each part of a URL

https://user@example.com:8080/docs/a%20file.pdf?q=caf%C3%A9&tag=a%26b#sec%202
\___/   \__/ \_________/ \__/ \__________________/ \____________________/ \_____/
scheme  user    host     port        path                 query          fragment

In the path, encode ? and #, which would end the path, and / when it belongs to data. :, @, =, & and + are allowed in path segments, and + means a literal plus.

In the query, encode # (it would start the fragment), and &, = and + inside values, because form parsers treat them as separators or as spaces. /, ? and : are allowed in the query, but encoding them in values is harmless and avoids surprises.

In the fragment, the rules resemble the query. Browsers never send the fragment to the server.

The host is different. International domain names are not percent-encoded but converted to Punycode: münchen.de becomes xn--mnchen-3ya.de. Browsers show the Unicode form and send the ASCII form.

Form encoding: the + exception

HTML forms submitted with application/x-www-form-urlencoded use a variant of percent-encoding. Spaces become +, and a larger set of characters is encoded. This format predates RFC 3986 and is still what browsers send for GET forms and what URLSearchParams produces.

value         RFC 3986 (path/component)   form-urlencoded (query)
a b           a%20b                        a+b
a+b           a%2Bb                        a%2Bb
café          caf%C3%A9                    caf%C3%A9
x~y           x~y                          x%7Ey

Both sides must agree. A server decoding a query with form rules turns an unencoded + into a space, so plus signs in values must be sent as %2B. A server decoding a path with RFC 3986 rules leaves + as a plus.

Equivalence and normalisation

Two URLs that differ only in encoding may or may not identify the same resource. RFC 3986 says that encoding an unreserved character, or changing the case of hex digits, does not change meaning: /%7Euser and /~user are equivalent. Encoding a reserved character does change meaning: /a%2Fb is one path segment, while /a/b is two.

This matters for caches, signatures and security checks. A cache key, an allow-list or a signed URL compared as a raw string treats %7E and ~ as different. Normalise first: decode unreserved characters, upper-case hex digits and lower-case the scheme and host. Never decode reserved characters as part of normalisation.

Servers do not all agree on %2F in a path. Some decode it before routing, which lets ..%2F reach parent directories, and others reject it. Apache, for instance, returns 404 for %2F in paths unless AllowEncodedSlashes is enabled. If your routes carry identifiers that may contain slashes, encode them another way, such as Base64url.

Common mistakes

Encoding the whole URL with a component encoder turns https:// into https%3A%2F%2F. Encode each value separately and assemble the URL, or use a URL builder.

Encoding twice turns % into %25, so %20 becomes %2520. It usually happens when a library already encodes parameters and the caller encodes them too.

Not encoding at all works until a value contains &, # or +. ?redirect=/cart?id=1&step=2 gives the server two parameters, not one.

Using a legacy encoding produces %E9 for é instead of %C3%A9. Modern decoders reject it or show �. Always encode UTF-8 bytes.

Sources and further reading

RFC 3986 (Uniform Resource Identifier: Generic Syntax), sections 2.1 to 2.4 and 6, defines percent-encoding, reserved and unreserved characters and normalisation. The WHATWG URL Standard defines the percent-encode sets browsers use and the application/x-www-form-urlencoded format. RFC 3492 defines Punycode for internationalised domain names. The Apache HTTP Server documentation describes AllowEncodedSlashes.

Frequently asked questions

What is percent-encoding?

The mechanism URLs use to include characters that are not allowed or have a special meaning. The character is converted to UTF-8 bytes, and each byte is written as `%` followed by two hex digits, so a space becomes `%20` and `é` becomes `%C3%A9`.

Is URL encoding the same as percent-encoding?

Yes, the two names are used interchangeably. Strictly, "URL encoding" often refers to the form-urlencoded variant, where spaces become `+`, while percent-encoding follows RFC 3986, where spaces become `%20`.

Which characters do not need to be percent-encoded?

The unreserved characters: letters `A–Z` and `a–z`, digits, and `- . _ ~`. Reserved characters such as `/ ? & =` may appear literally only where they serve their structural role.

Why is é encoded as %C3%A9 and not %E9?

URLs use UTF-8, where `é` is the two bytes `C3 A9`. `%E9` is the single Latin-1 byte, which is not valid UTF-8 and is rejected or garbled by modern decoders.

Are %2f and %2F the same?

Yes. Hex digits in percent-encoding are case-insensitive, though RFC 3986 recommends upper case. Note that `%2F` is still different from a literal `/` in a path.

Do domain names use percent-encoding?

No. Internationalised domain names are converted to Punycode, for example `xn--mnchen-3ya.de` for `münchen.de`. Percent-encoding is used in the path, query and fragment.