URL encoding (percent-encoding) exists so that characters which have a structural meaning in a URL, or which are unsafe to transmit, can be carried as data without being misread. A URL is not free-form text: characters like /, ?, #, and & mark boundaries between the path, the query, the fragment, and individual parameters. When one of those characters needs to appear as literal data — a slash inside a filename, an ampersand inside a search term — it has to be replaced with a percent escape so the parser does not treat it as a delimiter. Get this right and links just work. Get it wrong and you get truncated parameters, 404s, or the infamous %2520.
How does percent-encoding actually work?
Percent-encoding replaces a character with a % followed by the two hexadecimal digits of its byte value. A space is byte 0x20, so it becomes %20. An ampersand is 0x26, so it becomes %26. For characters outside ASCII, the rule is: encode the UTF-8 bytes, one %XX per byte. The euro sign U+20AC is three bytes in UTF-8 (E2 82 AC), so it encodes to %E2%82%AC. This is why encoding and decoding must agree on UTF-8 — decode the same bytes as a different charset and you get mojibake.
The mechanical rule is simple, but the hard part is knowing which characters to encode, and that depends on where in the URL the character sits.
What are reserved vs unreserved characters?
The specification (RFC 3986) splits characters into groups. The unreserved characters are always safe and should never be encoded:
A-Z a-z 0-9 - _ . ~
The reserved characters have delimiting jobs. They are legal in a URL, but only in their structural role. If you want one as literal data, you must encode it. The reserved set is:
: / ? # [ ] @ (general delimiters)
! $ & ' ( ) * + , ; = (sub-delimiters)
The table below shows the ones that bite people most often:
| Character | Structural meaning | Encoded as |
|---|---|---|
/ |
Path segment separator | %2F |
? |
Starts the query string | %3F |
# |
Starts the fragment | %23 |
& |
Separates query parameters | %26 |
= |
Separates key from value | %3D |
+ |
Space, in form-encoded queries | %2B |
% |
Starts an escape sequence | %25 |
| space | Not allowed literally | %20 |
Notice % itself is in that list. Because the percent sign begins every escape, a literal percent in your data must become %25. Forgetting this is the root of double-encoding, which we come to below.
encodeURIComponent vs encodeURI: which do I use?
Use encodeURIComponent for a single value you are dropping into a URL, and encodeURI only for an entire URL you have assembled from trusted parts. The two JavaScript functions differ in exactly one way: which characters they leave alone.
encodeURI is designed to take a complete URL and make it safe without breaking its structure, so it deliberately does not encode the reserved delimiters. It leaves : / ? # & = + $ , ; @ untouched. That is correct if the string really is a whole URL — but disastrous if it is a query value that happens to contain an ampersand.
encodeURIComponent assumes the string is one component — a single path piece or one parameter value — so it encodes almost everything except the unreserved set. That is what you want when building a query string:
const term = 'jazz & blues';
const q = 'https://example.com/search?q=' + encodeURIComponent(term);
// -> ...?q=jazz%20%26%20blues (the & is safely %26)
// The wrong tool:
'https://example.com/search?q=' + encodeURI(term);
// -> ...?q=jazz%20&%20blues (the & survives and starts a NEW parameter)
In the second line the raw & is read as a parameter separator, so q becomes just jazz and a bogus empty parameter appears after it. This single mistake — reaching for encodeURI when you meant encodeURIComponent — is one of the most common URL bugs in the wild. The table makes the split concrete:
| Input | encodeURI | encodeURIComponent |
|---|---|---|
a/b |
a/b |
a%2Fb |
a?b |
a?b |
a%3Fb |
a&b |
a&b |
a%26b |
a b |
a%20b |
a%20b |
Neither JavaScript function encodes ! * ' ( ), which are legal in a URL but reserved as sub-delimiters. If a downstream system is strict about those, encode them yourself. You can check any of these transformations quickly in the URL encoder, which runs entirely in your browser so you can paste a real value and see both the encoded and decoded forms without sending it anywhere.
Why is a space sometimes %20 and sometimes +?
Because two different encodings both describe URLs, and they disagree about spaces. Generic URI percent-encoding (RFC 3986) always writes a space as %20, in every part of the URL. But HTML form submission uses a slightly older scheme, application/x-www-form-urlencoded, in which a space becomes a plus sign +. That form encoding is what browsers use for GET form queries, so you see it constantly in query strings.
The practical consequences:
- In a path segment, a space is always
%20. A+in a path means a literal plus. - In a query string being read as form data,
+decodes to a space, and a literal plus must be%2B. %20is safe everywhere. If in doubt, use%20— it is never wrong;+for space only works in the form-encoded query context.
This is why a search for C++ can arrive as C followed by two spaces if some layer treated the query as form-encoded and did not escape the pluses. JavaScript’s encodeURIComponent encodes space as %20, not +; if you specifically need form encoding, use URLSearchParams, which produces + for spaces.
What causes double-encoding bugs?
Double-encoding happens when an already-encoded string is encoded a second time, so every % becomes %25 and escapes gain an extra layer. A space that was %20 becomes %2520, because the % in %20 is itself encoded to %25. The receiver decodes one layer, gets %20 back as literal text instead of a space, and your filename now visibly contains “%20”.
encodeURIComponent('a b') // 'a%20b'
encodeURIComponent(encodeURIComponent('a b')) // 'a%2520b' ← double-encoded
It usually creeps in when two layers each assume they own the encoding: a client encodes a value, then a framework or an HTTP library encodes the whole URL again; or a value is stored already-encoded and encoded once more on the way out. The fix is to decide on exactly one place that encodes, and to store and pass values in decoded (raw) form everywhere else.
The mirror-image bug is under-decoding: reading a parameter without decoding it, so your code sees jazz%20%26%20blues instead of jazz & blues. When a value round-trips oddly, decode it step by step and count the layers — each decode should strip exactly one. A client-side URL encoder and decoder is the fastest way to do this: paste the suspicious string, decode once, and see whether you land on clean text or on another layer of %25 escapes that reveals the double-encoding.
A quick checklist for getting it right
- Encoding one value for a query or path? Use
encodeURIComponent. - Encoding a whole assembled URL of trusted parts? Use
encodeURI. - Never encode the unreserved set (
A-Z a-z 0-9 - _ . ~). - Prefer
%20for spaces unless you are deliberately producing form-encoded data. - Encode exactly once. Store and pass raw values; encode only at the boundary where the URL is built.
- Remember encoding is about transport, not safety — it does not sanitize input or stop injection, so validate and escape for the final sink separately.
Encoding is a mechanical, reversible transformation: the same bytes go in and come back out. Almost every URL-encoding bug is really a mismatch about where a character sits or how many times the transformation ran. Keep those two questions in mind — which part of the URL, and how many layers — and the percent signs stop being mysterious.