URL encoding explained: %20 vs + and encodeURIComponent
· 6 min read
A search for C++ & C# returns results for “C” only. A redirect URL loses everything after its first &. A file named report 2026.pdf downloads as report%25202026.pdf. All three are URL encoding bugs, and all three come from the same few rules applied in the wrong place.
This article explains percent-encoding from the bytes up and shows which function to use for which part of a URL. The URL encoder and decoder lets you try every example. Type in either box and the other one updates.
Why URLs need encoding at all
A URL is text with structure. Some characters are delimiters: / separates path segments, ? starts the query, & separates parameters, = separates a name from its value, and # starts the fragment. If a value contains one of those characters, the parser can’t tell data from structure:
https://example.com/search?q=C++ & C#
Here & starts a new parameter, # starts a fragment, and in a query string + means a space. What the server receives as q is a “C” followed by three spaces, which is why the search only finds “C”.
Percent-encoding solves it. Each byte that isn’t allowed literally is written as % followed by two hex digits:
https://example.com/search?q=C%2B%2B%20%26%20C%23
The rules, from RFC 3986
RFC 3986 splits characters into three groups:
| Group | Characters | Rule |
|---|---|---|
| Unreserved | A–Z a–z 0–9 - . _ ~ |
Never need encoding |
| Reserved | : / ? # [ ] @ ! $ & ' ( ) * + , ; = |
Encode when used as data, not as a delimiter |
| Everything else | spaces, %, ", <, >, non-ASCII… |
Always encode |
Non-ASCII text is first converted to UTF-8, and then each byte is encoded. That’s why one character can become several escapes:
é → UTF-8 C3 A9 → %C3%A9
€ → UTF-8 E2 82 AC → %E2%82%AC
%20 or +?
Both mean “space”, in different contexts:
- In the path and in RFC 3986 generally, a space is
%20. - In
application/x-www-form-urlencoded, the format HTML forms use for query strings and POST bodies, a space is+, and a literal+must be written as%2B.
The trouble starts when the two meet. URLSearchParams in JavaScript uses form encoding:
new URLSearchParams({ q: 'a b+c' }).toString(); // "q=a+b%2Bc"
A server that decodes that query with plain percent-decoding (no form rules) sees a+b+c. A server that applies form rules to a path turns every + in a file name into a space. The rule of thumb: + for space only in query strings and form bodies, %20 everywhere else. When decoding a query string, treat + as a space. The URL decoder has a “Spaces as +” switch for exactly this case.
encodeURI vs encodeURIComponent
JavaScript has two built-in encoders, and choosing the wrong one is the most common URL bug in front-end code.
| Function | Leaves alone | Use it for |
|---|---|---|
encodeURIComponent |
A–Z a–z 0–9 - _ . ! ~ * ' ( ) |
One value: a query parameter, a path segment |
encodeURI |
the above plus ; , / ? : @ & = + $ # |
A whole URL that is already structured |
encodeURIComponent('a b&c=d/é');
// "a%20b%26c%3Dd%2F%C3%A9"
encodeURI('https://example.com/a b?q=é&x=1#top');
// "https://example.com/a%20b?q=%C3%A9&x=1#top"
encodeURI keeps &, = and # because it assumes they are structure. Using it on a single value is the bug behind “the redirect URL loses everything after &”:
// Wrong: the & inside next is treated as a new parameter
'/login?next=' + encodeURI('/cart?item=1&qty=2');
// Right
'/login?next=' + encodeURIComponent('/cart?item=1&qty=2');
In modern code, the simplest fix is to not concatenate at all:
const url = new URL('/login', location.origin);
url.searchParams.set('next', '/cart?item=1&qty=2');
The characters encodeURIComponent misses
encodeURIComponent leaves ! ' ( ) * unencoded, although RFC 3986 reserves them. Usually that’s harmless. It matters for OAuth 1.0 signatures and some AWS signing schemes, which require strict RFC 3986 encoding, and for values placed inside single-quoted HTML or JavaScript strings. The strict version:
const rfc3986 = (s) =>
encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
);
The URL encoder offers Component, RFC 3986 strict and Full URL modes side by side.
Double encoding
Encoding is not idempotent. Encode a value twice and the % itself gets encoded:
space → %20 → %2520
%25 is an encoded %, so %2520 decodes once to %20 and twice to a space. When you see %25 followed by two hex digits in a URL, something encoded an already encoded value. The usual culprits are a helper that encodes and a framework that encodes again, or a URL stored encoded in a database and encoded once more when it’s output.
The opposite bug, decoding twice, is a security problem: it can turn %252e%252e%252f into ../ after a path check has already passed. Decode exactly once, at the boundary where the value enters your code.
Malformed input
Decoding can fail. A % must be followed by two hex digits, and the bytes must form valid UTF-8:
decodeURIComponent('%E0%A4%A');
// URIError: URI malformed
Server code that decodes user input should catch this error instead of returning a 500. The URL decoder shows the error inline, names the line when you decode a list line by line, and keeps the last good result on screen.
Which part of the URL gets which encoding
| URL part | Encode with | Notes |
|---|---|---|
| Scheme, host | nothing (hosts use Punycode for non-ASCII) | пример.рф becomes xn--e1afmkfd.xn--p1ai |
| Path segment | encodeURIComponent |
So a / inside a name stays data |
| Query name/value | URLSearchParams or encodeURIComponent |
Form rules: space may be + |
| Fragment | encodeURIComponent |
Never sent to the server |
To see how a finished URL splits into these parts, and to edit its query parameters as a table, use the URL parser. The companion article on the anatomy of a URL goes through each part in detail.
Summary
Encode values, not whole URLs. Use encodeURIComponent or URLSearchParams for anything that goes into a query string or path segment, expect + only in form-encoded queries, decode once, and handle malformed input. When a URL looks wrong, paste it into the URL encoder and decoder and decode it step by step. The first %25 you find usually points at the bug.