Type a space into a URL and something downstream turns it into %20. URL encoding — percent-encoding, to use the specification's name — is that mechanism, and it exists for one reason: a URL is not just a string. It is a structured thing held together by delimiters, and any data you drop into it has to be stopped from looking like one of them.
Once you see it that way, the cases that feel arbitrary stop being arbitrary. The rules come from RFC 3986, and there are fewer than you would expect.
Why URL encoding turns a space into %20
Percent-encoding has exactly one form, given in RFC 3986 section 2.1: a percent sign followed by two hexadecimal digits giving the byte value — "%" HEXDIG HEXDIG. The space character is byte 0x20 in ASCII. Hex 20. So it becomes %20. Nothing special about spaces; the number falls out of the ASCII table, the same way & at 0x26 becomes %26.
A space has to be encoded at all because it is not in the set of characters a URL may contain as-is. RFC 3986 defines an unreserved set — ALPHA / DIGIT / "-" / "." / "_" / "~" — and anything outside it is either a reserved delimiter or must be percent-encoded.
Beyond ASCII the rule is two steps, set out in section 2.5: encode the character as UTF-8 octets first, then percent-encode each octet that is not unreserved. The RFC's own examples are %C3%80 for LATIN CAPITAL LETTER A WITH GRAVE and %E3%82%A2 for KATAKANA LETTER A — one character, two or three escapes, which is why encoded non-Latin text looks so much longer than the original.
Reserved, unreserved, and the rule that follows
RFC 3986 sorts punctuation into two reserved groups plus the unreserved set:
- gen-delims —
: / ? # [ ] @ - sub-delims —
! $ & ' ( ) * + , ; = - unreserved —
A-Z a-z 0-9 - . _ ~
And then the rule that governs everything else: if data for a URI component would conflict with a reserved character's purpose as a delimiter, then the conflicting data must be percent-encoded before the URI is formed.
Read that wording carefully. It does not say reserved characters are banned, but to encode one when your data would otherwise be mistaken for structure. Which gives the practical rule everything else rests on: you encode a value going into a URL component, not the finished URL. Running an encoder across a complete URL destroys the delimiters that made it a URL.
A character is only reserved where it sits
This is the part that looks inconsistent until you know why. Whether a character needs encoding depends on which component it lands in.
- Forward slash in a path. Section 3.3: a path is a sequence of segments separated by a slash. So a slash inside one segment — a file genuinely named
report 3/4.pdf— must become%2F, or the server reads two segments and hunts for a directory that does not exist. - Forward slash in a query. Perfectly fine. Section 3.4 says slash and question mark may represent data within the query component. Same character, different component, different answer.
- Ampersand in a query value. RFC 3986 lists
&and=as sub-delims without mandating what they separate, but by convention every server splits a query on them. An ampersand inside a value has to become%26, or one parameter silently becomes two. - Hash, anywhere in data. Always
%23.#starts the fragment, and the fragment is resolved by the client rather than sent to the server — so an unencoded hash gives you no error at all, it quietly truncates the URL before the request leaves the browser.
Common characters and their encodings
| Character | Encoded | Why it matters |
|---|---|---|
| space | %20 | Never legal raw; form data uses + |
# | %23 | Starts the fragment, never sent to the server |
% | %25 | The escape character; source of double-encoding |
& | %26 | Separates query parameters |
+ | %2B | Read as a space in form-encoded data |
/ | %2F | Separates path segments |
= | %3D | Separates a query key from its value |
? | %3F | Starts the query string |
: | %3A | Delimits scheme and port |
@ | %40 | Delimits userinfo in the authority |
Letters, digits, and - . _ ~ are the unreserved set and are never encoded. To check a specific string rather than read it off a table, the URL encoder and decoder on this site runs in your browser and shows both directions.
encodeURI vs encodeURIComponent
JavaScript gives you two functions, and you almost always want the second one.
encodeURIComponent()escapes everything exceptA-Z a-z 0-9 - _ . ! ~ * ' ( ).encodeURI()leaves those alone and the URL syntax characters:; / ? : @ & = + $ , #.
That difference is the entire point. encodeURI is for a URL you have already assembled and believe well-formed; encodeURIComponent is for one value you are about to drop into it. MDN's guidance is direct: to assemble string values into a URL dynamically, use encodeURIComponent() on each dynamic segment.
MDN's example makes the failure concrete. With a value of Ben & Jerry's, calling encodeURI on the whole assembled URL leaves the ampersand intact, so ?choice=Ben%20&%20Jerry's parses as two parameters and choice comes back as Ben with a trailing space. Encoding just the value gives ?choice=Ben%20%26%20Jerry's, which round-trips correctly. Note the apostrophe survives untouched — it is in that exception list.
The plus sign, and why form data is different
Here is the one that catches everyone. HTML form submissions and query strings built with URLSearchParams use application/x-www-form-urlencoded, which is not quite RFC 3986: in that format a space is written as + rather than %20. The WHATWG URL Standard specifies it in the serializer — if the space-as-plus flag is set and the byte is 0x20, append +.
So a literal plus sign in your data must be encoded as %2B, or it comes back as a space. In the browser:
new URLSearchParams({q: "a b"}).toString()givesq=a+bnew URLSearchParams("q=a+b").get("q")givesa bnew URLSearchParams("q=a%2Bb").get("q")givesa+b
MDN states it plainly: the URLSearchParams constructor interprets plus signs as spaces, which might cause problems. This is also why base64 breaks in query strings with no error to warn you — standard base64 uses + as one of its 64 characters, so a payload pasted raw into a query comes back with spaces where plus signs were. Use searchParams.append(), which encodes the plus as %2B, or switch to base64url. Worth remembering that base64 is not encryption either, just a transport-safe re-spelling of bytes.
Double encoding, and how %20 becomes %2520
Encode a string twice and the escape character gets escaped. % is byte 0x25, so % becomes %25, and therefore %20 becomes %2520 — the %25 standing in for the percent sign, followed by the literal characters 2 and 0.
RFC 3986 section 2.4 is explicit: implementations must not percent-encode or decode the same string more than once, because decoding an already-decoded string can misread a percent data octet as the start of an escape, and the confusion runs in reverse when encoding. In practice it happens when a value is encoded by your application code and then again by a framework helper, a redirect handler, or a CDN rewrite rule. The symptom is a visible %20 in rendered text, or a download named report%20final.pdf.
Fix it by finding which layer is doing the second encode, not by adding a decode at the end — a blanket extra decode corrupts every value that legitimately contained a percent sign. And %2520 is not automatically a bug: if the data really did contain the three characters %20, then %2520 is correct. You cannot tell from the string alone; you have to know what the value is meant to be. Which layer owns the escaping is the same question that makes JSON escaping worth being deliberate about.
Percent-encode or decode any string — runs entirely in your browser, nothing uploaded.
Open URL Encoder →Frequently asked questions
Because a space is not in the set of characters a URL may contain unencoded, and percent-encoding represents a byte as a percent sign plus two hex digits. The space character is byte 0x20 in ASCII, so it is written as %20. RFC 3986 defines the unreserved set that may appear as-is: letters, digits, hyphen, period, underscore and tilde.
Almost always encodeURIComponent. It escapes the URL syntax characters such as ampersand, equals, question mark and slash, which is exactly what you need when inserting a value into a query string or path segment. encodeURI deliberately leaves those characters alone because it assumes you are handing it a complete, already well-formed URL rather than a single value.
Because form data uses application/x-www-form-urlencoded, where a space is written as a plus sign rather than %20. The WHATWG URL Standard specifies this, and MDN notes that the URLSearchParams constructor interprets plus signs as spaces. To keep a literal plus, encode it as %2B — for example by using searchParams.append() rather than building the query string by hand.
Double encoding. The percent sign is itself byte 0x25, so encoding an already-encoded string turns %20 into %2520. RFC 3986 says implementations must not encode or decode the same string more than once. Track down which layer is encoding twice rather than adding a decode at the end, since a blanket decode will corrupt values that genuinely contain a percent sign.