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:

  • encodeURIComponent encodes everything reserved — correct for a single value being inserted into a query string.
  • encodeURI leaves 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.