URL Encoding: Why a Space Is Sometimes %20 and Sometimes +
There are two different encodings both loosely called "URL encoding", and they disagree about the space character. Mixing them up corrupts search queries, breaks file paths containing a plus sign, and produces the %2520 that means somebody encoded something twice.
The short version
- Percent-encoding (RFC 3986) encodes a space as
%20. Form encoding (application/x-www-form-urlencoded) encodes it as+. - Form encoding is correct in a query string. In a path,
+is a literal plus — decoding it as a space corrupts data. encodeURIis for a whole URL;encodeURIComponentis for a single component. They are not interchangeable.%2520is the signature of double encoding: the%of a%20got encoded again.
Why encoding is needed at all
A URL is a structured string where certain characters are structural: ? starts the query, & separates parameters, / separates path segments, # starts the fragment. If a value you are inserting contains one of those characters, the parser cannot tell your data from the URL's own punctuation.
Percent-encoding solves this by replacing a character with % followed by the two hex digits of its byte value. RFC 3986 divides characters into three groups:
| Group | Characters | Rule |
|---|---|---|
| Unreserved | A-Z a-z 0-9 - . _ ~ | Never need encoding; encoding them is legal but pointless |
| Reserved (gen-delims) | : / ? # [ ] @ | Structural. Encode when they are data |
| Reserved (sub-delims) | ! $ & ' ( ) * + , ; = | Structural within components. Encode when they are data |
Everything else — spaces, non-ASCII, control characters — must be encoded. For non-ASCII, the character is first converted to UTF-8 bytes and then each byte is percent-encoded, so é becomes %C3%A9 (two bytes, two escapes).
The two encodings
Here is the source of most confusion. HTML form submission predates RFC 3986 and defined its own variant, application/x-www-form-urlencoded, which differs in one visible way: a space becomes +, not %20. A literal plus must therefore itself be encoded as %2B.
Value: hello world & C++
Percent-encoding (RFC 3986): hello%20world%20%26%20C%2B%2B
Form encoding: hello+world+%26+C%2B%2B
Both are correct — in their own context. The rules of thumb:
- Query strings: either works. Servers and frameworks form-decode query strings, so
+is read as a space. This is why search URLs are full of plus signs. - Path segments: percent-encoding only. There is no form encoding in a path, so
+is a literal plus character. - Request bodies with content type
application/x-www-form-urlencoded: form encoding, by definition.
A file named C++ notes.pdf uploaded to a path-based storage key becomes /files/C%2B%2B%20notes.pdf. If any layer in the chain form-decodes that path instead of percent-decoding it, the pluses turn into spaces and you are now looking for C notes.pdf, which does not exist. This is why "the file uploads fine but 404s on download" reports so often involve a plus, a space, or both.
The four JavaScript functions, and which to use
JavaScript ships two encoding functions with a deliberately different scope, plus URLSearchParams:
| Function | Leaves unencoded | Use for |
|---|---|---|
encodeURI() |
Unreserved plus ; , / ? : @ & = + $ # |
An entire URL whose structure is already correct |
encodeURIComponent() |
Unreserved plus ! ' ( ) * |
A single value going into a path or query |
URLSearchParams |
Form encoding (space → +) |
Building query strings — handles escaping for you |
The failure mode of using encodeURI where you meant encodeURIComponent is that structural characters in your data survive and become structure:
const q = "a=1&admin=true";
"/search?q=" + encodeURI(q)
// → /search?q=a=1&admin=true two parameters! q and admin
"/search?q=" + encodeURIComponent(q)
// → /search?q=a%3D1%26admin%3Dtrue one parameter, correct
That is a parameter-injection bug, and depending on what the extra parameter controls it can be a security one.
The gap in encodeURIComponent
Note from the table that encodeURIComponent leaves !, ', (, ) and * alone, even though RFC 3986 classes them as sub-delims. It predates the RFC. If you need strict compliance — some OAuth signature schemes and AWS request signing do — patch the output:
const strict = s => encodeURIComponent(s)
.replace(/[!'()*]/g, c =>
'%' + c.charCodeAt(0).toString(16).toUpperCase());
RFC 3986 also says producers should emit uppercase hex digits (%C3, not %c3), while decoders must accept either. Signature schemes that hash the encoded string care about this; most other things do not.
Encode and decode, with the parameters broken out
Layerbit's URL Encoder / Decoder has a Smart mode that preserves a URL's structure while encoding only its query values, a Raw mode for strict component encoding, an editable parameter grid for rebuilding a query, and a batch mode for processing a list of URLs at once.
Double encoding, and how to recognise it
Encoding is not idempotent. Because % is itself a character that needs encoding (as %25), running an encoder over already-encoded text mangles it:
"hello world" → "hello%20world" (once)
"hello%20world" → "hello%2520world" (twice)
"hello%2520world" → "hello%252520world" (three times)
So %2520 in a log line or a URL bar is a diagnostic: something encoded a value that was already encoded. Likewise %253A is a double-encoded colon and %2526 a double-encoded ampersand.
The cause is almost always a value crossing layers, each of which believes it is responsible for encoding: the client encodes, a proxy or SDK encodes again, or a redirect URL is encoded once when built and once when appended as a parameter. The discipline that prevents it:
- Encode once, at the point where you assemble a raw value into a URL.
- Store and pass around decoded values internally. Encoding is a serialisation concern, not a data property.
- Decode once, at the boundary where you parse the URL.
Note that "decode until it stops changing" is a tempting fix and a genuinely dangerous one — it is how path-traversal filters get bypassed, because %252e%252e%252f passes a check for ../ and then becomes ../ after the second decode. Decode exactly once, then validate.
Encode per component, never the whole URL
The rule that resolves most remaining questions: encoding applies to individual components before the URL is assembled, because only then does the code know which characters are data and which are structure.
// Wrong - the URL is already built, so & and = are indistinguishable
encodeURIComponent(`/search?q=${term}&page=${n}`);
// Right - each value encoded while it is still a value
`/search?q=${encodeURIComponent(term)}&page=${encodeURIComponent(n)}`;
// Better - let the platform handle it
const u = new URL("/search", location.origin);
u.searchParams.set("q", term);
u.searchParams.set("page", n);
That third form is the one to reach for. URLSearchParams encodes each value correctly, handles repeated keys, and cannot produce an injection because it never treats your data as syntax. Its only quirk is the one we started with: it form-encodes, so spaces come out as +. In a query string, that is exactly right.
Two encodings, one question
Whenever you are unsure which form to use, the question to ask is not "which is correct?" but "which layer will decode this, and how?". A query string will be form-decoded, so + is a space. A path will be percent-decoded, so + is a plus. The encoding must match the decoder, and the decoder is determined by position in the URL.
Get that pairing right and the rest follows: encode once, per component, at assembly time; decode once, at parse time; and let URLSearchParams do the work wherever it can.