URL Encoding Explained: Why Spaces Become %20
· 2 min read
URLs were designed in an era of 7-bit ASCII and strict grammars, and the design still binds every URL today: only a limited set of characters may appear literally. Everything else must be percent-encoded — written as % followed by the byte's value in hexadecimal. A space becomes %20. An ampersand becomes %26. And café becomes caf%C3%A9.
The character classes
RFC 3986 divides characters into three groups:
Unreserved — always safe, never need encoding:
A–Z a–z 0–9 - _ . ~
Reserved — legal, but they mean something in URL grammar: : / ? # [ ] @ ! $ & ' ( ) * + , ; =. A literal ? starts the query string; a literal & separates parameters. Use one as data — say, an ampersand inside a search term — and it must be encoded, or it will be parsed as structure.
Everything else — spaces, quotes, angle brackets, control characters, and all non-ASCII — must always be encoded.
How non-ASCII text is encoded
Percent-encoding operates on bytes, not characters. Text is first serialized to UTF-8, then each byte is encoded:
é → UTF-8: 0xC3 0xA9 → %C3%A9
€ → UTF-8: 0xE2 0x82 0xAC → %E2%82%AC
日 → UTF-8: 0xE6 0x97 0xA5 → %E6%97%A5
This is why one visible character can balloon into nine URL characters, and why non-ASCII slugs get so unwieldy — the argument for transliterating slugs to ASCII at creation time.
The space problem: %20 vs +
Spaces have two encodings, for historical reasons. Percent-encoding proper uses %20. But HTML form submission (the application/x-www-form-urlencoded format) encodes spaces as +. Both appear in the wild, and decoding must know which convention it's reading: a literal plus sign in form data is %2B, while in a path, + is just a plus sign. When a decoder shows you 50%20off and 50+off for the same value, this is why.
Encode the part, not the whole
The most common encoding bug is applying the wrong scope. JavaScript exposes both options:
encodeURIComponentencodes everything reserved — correct for a single value being inserted into a query string.encodeURIleaves structural characters (:/?&=#) intact — correct only for encoding a complete URL without destroying its grammar.
Encode a full URL with encodeURIComponent and you get https%3A%2F%2Fexample.com — a broken link. Encode a query value with encodeURI and any & inside it silently splits your parameter in two. The encoder/decoder offers both modes explicitly so the choice is visible.
The double-encoding trap
Encoding is not idempotent. Encode 50% off once and you get 50%25%20off. Encode it again and the %25 becomes %2525 — now the text decodes to 50%25 off. Double encoding happens when two layers of a system each "helpfully" encode: a form, then a redirect; a CMS, then a link builder. The telltale signature is %25 appearing where you expected %. The fix is architectural: encode exactly once, at the last moment before the value enters a URL, and decode exactly once at the boundary where you consume it.
To see any of this concretely, paste a gnarly URL into the decoder and watch it turn back into text — or type text with symbols and watch the %XX sequences appear.