URL encoding, explained — why your %20 keeps breaking
Type a space into a URL and the browser quietly turns it into
%20. Paste a link with an emoji and it becomes a wall of
percent signs. Encode something twice and the server hands back
%2520 and you stare at it like it's a typo.
All of it is one small system — percent-encoding, the way URLs carry characters they weren't originally designed for. It's small enough to learn in six minutes, and once you do, a whole category of "why is this link broken" bugs disappears. Here it is.
- Why URLs are ASCII-only, and what percent-encoding actually is
- Reserved vs. unreserved characters — and why context decides everything
- The
%20vs.+space confusion, settled once and for all - The
%2520double-encoding bug and how to avoid it encodeURIComponentvs.encodeURI— which one you want
Why URLs need encoding at all
URLs were built to travel through any system that handles text — and that
means they're restricted to a safe subset of ASCII. No
spaces, no emoji, no accented characters, and a set of
reserved characters that carry syntactic meaning:
: / ? # & = and friends.
So when you need one of those characters as data — a space
in a filename, an & inside a search query, a
/ in a parameter — you can't just put it there. It would be
read as syntax. Percent-encoding is the escape hatch: replace the character
with a % followed by two hex digits representing its byte
value.
space → %20 ASCII 32 = 0x20
/ → %2F when it's DATA, not a path separator
= → %3D when it's data, not a key/value separator
& → %26 when it's data, not a parameter separator
é → %C3%A9 non-ASCII → UTF-8 bytes, then each byte encoded
🙂 → %F0%9F%99%82 an emoji is 4 UTF-8 bytes → 4 groups
# a whole string:
"café & co" → caf%C3%A9%20%26%20co
That's the whole mechanism. Non-ASCII characters get converted to UTF-8 bytes first, then each byte is percent-encoded — which is why a single emoji balloons into twelve characters.
Reserved vs. unreserved — context is everything
Characters split into two camps, and the difference is whether the URL grammar gives them a job:
UNRESERVED — never need encoding:
A-Z a-z 0-9 - _ . ~
RESERVED — syntax characters; encode only when they're DATA:
: / ? # [ ] @ ! $ & ' ( ) * + , ; =
# the same character, two contexts:
https://example.com/a/b / is a path separator — leave it alone
https://example.com/?path=a%2Fb / is DATA here — encode it
This is the idea that makes everything else click: a character only needs
encoding when it would be misread as syntax. The
/ in a path is doing its job; the / inside a
query parameter is an imposter. Same character, different context,
different treatment.
The space problem — %20 vs. +
Spaces are the character everyone actually runs into, and they have two encodings, which is the source of most of the confusion:
# in a PATH, a space is always %20
https://example.com/my%20file.txt
# in a QUERY STRING (form encoding), a space can be +
https://example.com/search?q=hello+world
# %20 works in BOTH places. + means "space" ONLY in query strings.
The +-for-space convention comes from HTML form encoding
(application/x-www-form-urlencoded) and applies
only in query strings. In a path, + is a
literal plus sign. Use + in a path and the server reads a
plus; your "fix" just created a different filename. When in doubt,
%20 is correct everywhere.
Encoding and decoding in JavaScript
JavaScript gives you two encode functions, and picking the wrong one is half of all encoding bugs:
// encode a VALUE — what you want 95% of the time
encodeURIComponent("café & co") // "caf%C3%A9%20%26%20co"
// encode a whole URL — preserves :/?# so it stays a valid URL
encodeURI("https://x.com/a b") // "https://x.com/a%20b"
// decode
decodeURIComponent("caf%C3%A9") // "café"
// building a query string the safe way:
const url = "https://api.example.com/search?q=" + encodeURIComponent(userInput);
encodeURIComponent for values you're putting into
a URL. encodeURI only when you're encoding a complete URL
and need to keep its syntax intact. If you're asking "which one?" the
answer is almost always encodeURIComponent.
The double-encoding bug — %2520
Here's the bug that produces the weirdest-looking output. The
% character is itself a reserved character — so if
you encode something that's already encoded, the % gets
encoded to %25:
# encode once — correct
" " → %20
# encode twice — the bug (% itself becomes %25)
%20 → %2520
# the server decodes ONCE and gets the literal text "%20"
Double-encoding happens when a value gets encoded, then the whole URL —
or the value again — gets encoded a second time. The server decodes
once and treats %20 as literal text. The fix is a
discipline, not a function: encode each value exactly once, at
the moment you build the URL, and never re-encode a URL that's already
assembled.
The mirror-image mistake is encoding the whole URL — turning
https:// into https%3A%2F%2F. That destroys the
syntax that makes it a URL. Values get encoded; the URL's own structure
does not.
The mistakes that actually happen
- Encoding the whole URL instead of just the values — the separators get encoded and the URL stops being a URL.
- Using
+for a space in a path. It's a literal plus there.%20is the only space encoding that works everywhere. - Double-encoding — the
%2520above. Encode once, at the point of assembly. - Putting raw user input into a URL. Anything a user typed must pass through
encodeURIComponentfirst — an unencoded&or#silently truncates or corrupts the request. - Assuming
decodeURIComponentnever fails. It throws on malformed input (a lone%, a bad hex pair) — wrap it if the input isn't guaranteed.
The full grammar — every reserved character and the exact rules — is
RFC 3986, and the
MDN page for encodeURIComponent
is the reference you'll actually keep open.
Wrap-up
URLs are ASCII, so anything else travels as % plus two hex
digits. Reserved characters only need encoding when they're data, spaces
are %20 everywhere (+ only in query strings),
and the two bugs that actually happen are double-encoding and encoding the
whole URL. Encode values once, with encodeURIComponent, and
the percent signs stop being mysterious.
Encoding is one of those topics that pairs with everything else on this blog — the base64 vs. base64url post covers the other encoding that trips people up, and when a mangled URL finally does reach the server, the HTTP status lookup will tell you what it thinks of the result. And if your encoded request is correct but still getting blocked, that's usually a CORS conversation, not an encoding one.