URL Encoding Explained
Every time you put a space, an accented letter, an ampersand or an emoji into a link, something has to happen first — because a URL cannot literally contain those characters. That something is percent-encoding, the scheme defined in RFC 3986 that rewrites unsafe characters as % followed by two hex digits. It looks arbitrary until you see the two rules underneath it: URLs are limited to a small ASCII alphabet, and a handful of characters are reserved to carry a URL's structure. This guide works through both from first principles — reserved versus unreserved sets, how %XX escapes are built, the space that becomes %20 or a +, why JavaScript ships two different encoders, how non-ASCII text turns into multiple bytes, and the double-encoding bugs that quietly break links in production.
Why URLs need encoding at all
A URL is, at heart, a line of text that has to survive being typed, copied, logged, parsed by a browser, sent in an HTTP request line and read by a server — often by software written decades apart. To make that reliable, the specification restricts what a URL may contain to a small, safe subset of US-ASCII: the letters A–Z and a–z, the digits 0–9, and a short list of punctuation. Anything outside that — a space, a tab, a curly quote, the letter é, a Chinese character, an emoji — has no legal representation in a raw URL. Percent-encoding is the escape hatch: it lets any byte ride inside a URL by spelling that byte out as % plus its two-digit hexadecimal value.
There is a second, subtler reason encoding exists. A few ASCII characters are perfectly printable but are reserved — they do structural work. A / separates path segments, a ? begins the query string, a # starts the fragment, and & and = split query parameters into name/value pairs. If your data happens to contain one of those characters, you must encode it, or a parser will read it as structure and misread the URL.
Reserved vs unreserved characters
RFC 3986 sorts the ASCII characters into three buckets, and the whole scheme falls out of knowing which is which.
- Unreserved — always safe, never need encoding: the letters, the digits, and the four marks
-_.~. A correct encoder leaves these exactly as they are. - Reserved — the delimiters that carry structure:
:/?#[]@(the general delimiters) and!$&'()*+,;=(the sub-delimiters). These are legal in a URL, but only where they mean structure; used as data they must be escaped. - Everything else — spaces, control codes, and every non-ASCII character — has no place in a raw URL and must be percent-encoded.
The distinction that trips people up is "reserved but harmless here." An & inside a path segment is usually fine because nothing in a path splits on it; the same & inside a query-parameter value is dangerous because the query parser does split on it. Whether a reserved character needs encoding depends on which part of the URL it sits in — which is exactly why the two JavaScript encoders below behave differently.
How a %XX escape is built
An escape is always a literal percent sign followed by two hexadecimal digits — the value of a single byte, from %00 to %FF. Hexadecimal is base-16, so each digit is 0–9 or A–F, and two of them cover all 256 possible byte values. To encode a space, you take its ASCII code, 32, write it in hex as 20, and prefix a percent sign:
The same recipe works for every reserved character: & is code 38 = %26, ? is 63 = %3F, / is 47 = %2F, # is 35 = %23. Decoding just runs the process backwards: find a %, read the next two hex digits, and replace all three characters with the single byte they name. Because the percent sign is itself the trigger, a literal percent in your data must be encoded too — as %25 — or a decoder will try to read the following two characters as hex and mangle them.
The space problem: %20 vs +
The space is the one character with two competing encodings, and it causes real confusion. Under RFC 3986 — the rule for URLs in general — a space is %20, full stop. But there is an older, narrower rule for the application/x-www-form-urlencoded format, the way HTML forms have submitted data since the early web. In that format, and only that format, a space is encoded as a plus sign +, and a literal plus must therefore be escaped as %2B so it is not mistaken for a space.
So both %20 and + can mean a space, depending on where you are. The safe default — and what encodeURIComponent produces — is %20, which is valid everywhere including inside a query string; most servers happily accept it. The + form only matters when you are hand-building a classic form submission or matching a server that decodes with form rules. If you decode a + back to a literal plus when the source meant a space (or vice versa), you get a subtle, one-character-wrong bug that is easy to miss.
encodeURIComponent vs encodeURI
JavaScript gives you two built-in encoders, and picking the wrong one is the single most common URL-encoding mistake. They differ only in which reserved characters they leave alone.
encodeURIComponent assumes it is handed one piece of a URL — a single query value, one path segment, a fragment. So it escapes the reserved delimiters too (: / ? # [ ] @ ! $ & ' ( ) * + , ; =), leaving unescaped only the unreserved set plus, for historical reasons, the five sub-delimiters ! ' ( ) *.
encodeURI assumes it is handed a whole, already-structured URL and must not break it. So it deliberately leaves the structural delimiters intact and only escapes spaces and clearly-illegal characters. Watch the value café & tools split the two apart:
Both escape the spaces and the accented é, but encodeURIComponent turns the & into %26 while encodeURI keeps it as a literal &. Drop that encodeURI output after ?q= and the surviving & is read as the start of a new parameter — your value is silently truncated to café. The rule is short: use encodeURIComponent for a value you are placing into a URL, and encodeURI only to tidy an entire link you do not want to take apart.
Non-ASCII: one character, several bytes
Percent-encoding escapes bytes, not characters — and a non-ASCII character is more than one byte. Modern URLs encode text as UTF-8 first, then percent-encode each resulting byte. The letter é is a single character but two UTF-8 bytes, 0xC3 0xA9, so it becomes two escapes; an emoji is four bytes and so four escapes:
This is why a single accented letter turns into a run of %XX pairs rather than one: you are seeing its individual bytes. Decoding reverses it exactly — the byte sequence is reassembled and re-read as UTF-8 — so text round-trips character-for-character as long as both ends agree on UTF-8, which today they almost always do. Legacy systems that assumed a different encoding (say Latin-1) are the main place this breaks.
Two bugs that bite in production
Double-encoding. This is running the encoder on a string that is already encoded. The percent signs themselves get escaped, so %20 becomes %2520 (because the % becomes %25), and the link arrives with a literal "%20" showing in it instead of a space. It usually happens when a value passes through two layers that each "helpfully" encode it. The fix is discipline: encode a raw value exactly once, at the moment it enters the URL. If you are unsure whether a string is already encoded, decode it and see whether it changes.
Encoding a whole URL as if it were one component. If you pass a complete address like https://example.com/a?b=c through encodeURIComponent, it escapes the :, /, ? and = that make it a URL, producing https%3A%2F%2Fexample.com%2Fa%3Fb%3Dc — which is correct only if you genuinely wanted that address as a value inside another URL (a redirect target, for instance). Do it by accident and the receiving code sees one opaque blob instead of a link. The mirror image is using encodeURI on a single value, which leaves & and = live and breaks your query string. Both bugs come from the same root cause: encode components with the component encoder and whole URLs with the whole-URL encoder, and never confuse the two.
Paste text and watch it percent-encode live, with a Component / Full-URL scope toggle, full UTF-8 support and clear errors on malformed input — nothing leaves your browser.
Putting it together
Percent-encoding stops feeling arbitrary once the two rules underneath are clear: a URL may only carry a small ASCII alphabet, and a few characters are reserved to carry structure. Everything follows from that — %XX spells out a byte in hex, spaces become %20 (or the form-only +), non-ASCII text is UTF-8 bytes each escaped in turn, and the choice between encodeURIComponent and encodeURI is simply "am I encoding a piece or the whole thing?" Keep those straight, encode each value exactly once, and the double-encoding and broken-query bugs that eat afternoons simply stop happening.
Open the URL Encoder / Decoder → to percent-encode or decode any text and watch the scopes diverge for yourself, or browse all Polyatic tools.