URL Encoding Explained: Percent-Encoding and Where It Bites
Published 7/7/2026 · 16 min read · Developer tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 6 sources
Percent-encoding replaces a byte with a percent sign and two hexadecimal digits. Which bytes need replacing depends on which part of the URL you are in, and that is why the subject feels inconsistent. RFC 3986 defines unreserved characters that never need encoding — A-Z, a-z, 0-9, hyphen, period, underscore and tilde — and reserved characters that carry structural meaning: the gen-delims : / ? # [ ] @ and the sub-delims ! $ & ' ( ) * + , ; = . A reserved character must be encoded when it appears as data rather than as structure. So / is perfectly legal inside a path and must become %2F inside a query value, because there it would otherwise be read as part of the path. JavaScript gives you three functions that disagree on this. Run them on a b/c?d=café+e&f#g~h*i(j): encodeURI returns a%20b/c?d=caf%C3%A9+e&f#g~h*i(j), encodeURIComponent returns a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j), and the deprecated escape returns a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29. Use encodeURIComponent for every individual value, encodeURI only for a whole URL you already trust, and escape never — it emits Latin-1, so é becomes %E9 rather than the correct UTF-8 %C3%A9.
Percent-encoding is decided per URL component, which is the whole source of the confusion. A slash is legal in a path and must be escaped in a query value; a space is %20 in a path and may be + in a form body. Here are the exact RFC 3986 sets, the three JavaScript functions that disagree, and the traps.
The rule is per component, not per URL
A URL is not one string, it is a sequence of labelled parts: scheme, host, path, query, fragment. Each part has its own idea of which characters are structure and which are data, and percent-encoding exists to tell the two apart. A slash inside a path is structure — it separates segments — so it stays as it is. The same slash inside a query value is data, and it must be written %2F, otherwise a parser reading the query has no way to know you meant a literal character rather than a piece of the path that ended up in the wrong place.
The consequence shows up the moment you build a URL by concatenation. Suppose a filename is 2026/08 report.pdf and it has to sit in a path segment. Encode the value and you get /files/2026%2F08%20report.pdf, one segment as intended. Skip the encoding and you get /files/2026/08 report.pdf, three segments and a space, pointing at something that does not exist. The same asymmetry hits query values: ?note=rock&roll parses as two parameters, note with the value rock and an empty roll, while ?note=rock%26roll parses as the single value you meant.
Reserved and unreserved, exactly as RFC 3986 defines them
The unreserved set is small and worth memorising: the letters A to Z in both cases, the digits 0 to 9, and exactly four punctuation marks — hyphen, period, underscore and tilde. Those never require encoding anywhere in a URL, and encoding them anyway is legal but pointless, since %41 and A denote the same character and a conforming parser treats them identically.
The reserved set is split in two. The gen-delims are the characters that separate the major components: colon, slash, question mark, hash, opening and closing square brackets, and the at sign. The sub-delims are the ones that structure the inside of a component: exclamation mark, dollar, ampersand, apostrophe, opening and closing parentheses, asterisk, plus, comma, semicolon and equals. Everything not in the unreserved or reserved set — control characters, space, quotation mark, angle brackets, backslash, caret, backtick, braces, pipe, and every byte above 127 — must always be percent-encoded.
One detail catches people out. encodeURIComponent leaves exclamation mark, apostrophe, parentheses and asterisk untouched, and all four are sub-delims under RFC 3986. Those characters are legal where the function is normally used, so this is harmless in ordinary cases, but if you are producing a value for a system that follows RFC 3986 strictly — some signature schemes and OAuth-style canonicalisations do — you have to escape them yourself afterwards. Note also that square brackets are always escaped by both JavaScript encoders, giving %5B and %5D, because they were added to the reserved set for IPv6 literals after the functions were specified.
Three JavaScript functions on one string, and the plus-sign exception
Take a b/c?d=café+e&f#g~h*i(j) and run all three. encodeURI produces a%20b/c?d=caf%C3%A9+e&f#g~h*i(j): the space and the accented letter are encoded, everything structural is left alone. encodeURIComponent produces a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j): the slash, question mark, equals, plus, ampersand and hash are all escaped, because in this function's world the whole string is one value. escape produces a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29, which is different from both.
Enumerate the ASCII range and the difference becomes precise. The two modern functions differ on exactly eleven characters: # $ & + , / : ; = ? @ are left alone by encodeURI and escaped by encodeURIComponent. That list is the reserved set, which tells you what the two functions are for. encodeURI assumes the string is already a complete URL whose delimiters must survive; encodeURIComponent assumes the string is one value that must not be allowed to introduce any delimiter at all.
escape is a different animal and should never be used. It predates the modern specifications, and it encodes to Latin-1 rather than UTF-8: é becomes %E9 instead of the correct %C3%A9, and anything above U+00FF becomes a non-standard %uXXXX sequence, so the euro sign comes out as %u20AC. It also leaves the plus sign and the at sign and the forward slash unescaped, all of which are dangerous inside a query value, while needlessly escaping the tilde and parentheses. It survives in the language only for backward compatibility, in the annex reserved for features that exist but should not be relied on.
Percent-encoding as defined by RFC 3986 has exactly one representation for a space: %20. It works everywhere — path, query, fragment. The plus sign as a space belongs to a different, older mechanism: the application/x-www-form-urlencoded serialisation used by HTML forms, in which spaces become plus signs and a literal plus must become %2B. Browsers use that form for the query string of a GET form submission, which is why you see both conventions in query strings in the wild.
The consequence is a decoding bug that is easy to write and hard to see. decodeURIComponent("a+b") returns a+b, with the plus intact, because decodeURIComponent implements RFC 3986 and knows nothing about form encoding. Feed the same string to a form-aware parser and you get a b. So the correct decoder depends on how the string was produced. In practice the reliable move is to stop hand-rolling either side: build query strings with URLSearchParams, which serialises a space as + and escapes a literal plus as %2B, and read them back with URLSearchParams, which reverses exactly the same rules.
Non-ASCII goes through UTF-8 first
Percent-encoding operates on bytes, not on characters, so a non-ASCII character has to be turned into bytes before it can be escaped. The modern rule is UTF-8, then one percent-escape per byte. é is a single character encoded as the two bytes c3 a9, so it becomes %C3%A9. The euro sign is three bytes, e2 82 ac, so it becomes %E2%82%AC — nine characters for one symbol. An emoji such as U+1F600 is four bytes and becomes %F0%9F%98%80, twelve characters.
This is where escape betrays you, since it maps the same é to the single byte %E9, its Latin-1 code point. A server decoding as UTF-8 sees an invalid byte sequence and either throws or produces a replacement character, and the failure only shows up on the accented rows of your data. RFC 3986 does not itself mandate a character encoding — it predates the universal adoption of UTF-8 and merely recommends it for new schemes — but the WHATWG URL Standard, which is what browsers actually implement, specifies UTF-8 throughout. Treat UTF-8 as the only correct answer.
Double encoding, and how %2520 happens
The percent sign is itself a reserved character, so encoding an already-encoded string escapes the escapes. Start with a b. Encode once: a%20b. Encode that: a%2520b, because the percent became %25. Encode again: a%252520b. Each round adds three characters and one more required decoding pass, and the string grows without ever throwing an error.
In real systems this happens when a value crosses several layers, each of which helpfully encodes what it was handed: a client encodes, a gateway encodes again, a framework encodes on the way into a template. The symptom is a literal %20 appearing in a page or a filename where a space should be, or a 404 on a path that looks correct. The cure is a discipline rather than a trick: decide exactly one place in the pipeline that owns encoding, encode there, and pass raw values everywhere else. If you must decode defensively, decode once and check whether the result still contains a percent sign followed by two hex digits before deciding to decode again — and be aware that blind repeated decoding is itself a security problem, because it can turn %252e%252e into .. and reopen a path traversal a single decode had closed.
The host is a different mechanism: IDN and punycode
Percent-encoding does not apply to the domain name. DNS labels are restricted to letters, digits and hyphens, so internationalised domains use a completely separate transformation: Punycode, defined in RFC 3492 and wrapped by the IDNA specifications. münchen.de becomes xn--mnchen-3ya.de, bücher.example becomes xn--bcher-kva.example, and a Japanese domain such as the two characters for Japan followed by .jp becomes xn--wgv71a.jp. The xn-- prefix marks the label as encoded; what follows is the ASCII characters in order, then a separator, then instructions for reinserting the non-ASCII ones.
You can watch the two mechanisms work side by side. Parse https://münchen.de/straße?q=über alles with a standard URL parser and the result is https://xn--mnchen-3ya.de/stra%C3%9Fe?q=%C3%BCber%20alles: the host went through Punycode, the path and query went through UTF-8 percent-encoding, and neither touched the other's territory. This separation is not cosmetic. It is why a percent sequence in a hostname is not decoded the way you might expect, and why homograph attacks — registering a domain whose Unicode characters look like another's — are a Punycode-layer problem that browsers address with display rules rather than with encoding.
Four rules that prevent most of the trouble
First, never assemble a URL by string concatenation when a URL builder is available. new URL() and URLSearchParams know which component they are in and encode accordingly, which is exactly the knowledge a template literal lacks. Second, encode values, not URLs: run encodeURIComponent on each individual path segment and each individual query value, and reserve encodeURI for a finished URL you built yourself. Third, encode once, at a single owning layer, and pass raw values everywhere else — that alone removes the entire class of %2520 bugs. Fourth, delete escape from your codebase; it encodes Latin-1 and leaves dangerous characters alone, and there is no situation in which it is the right answer.
| Encoder | Output on a b/c?d=café+e&f#g~h*i(j) | Leaves untouched (beyond letters and digits) | Non-ASCII | Use it for |
|---|---|---|---|---|
| encodeURIComponent | a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j) | - . _ ~ ! ' ( ) * | UTF-8 then percent-encoded | Every individual value: one path segment, one query value, one fragment |
| encodeURI | a%20b/c?d=caf%C3%A9+e&f#g~h*i(j) | Everything the component encoder keeps, plus # $ & + , / : ; = ? @ | UTF-8 then percent-encoded | A whole URL you assembled yourself and already trust |
| escape (deprecated) | a%20b/c%3Fd%3Dcaf%E9+e%26f%23g%7Eh*i%28j%29 | * + - . / @ _ — and it escapes ~ ( ) that the others keep | Latin-1 for U+00FF and below, then the non-standard %uXXXX | Nothing. It is in the standard only for backward compatibility |
| URLSearchParams (form encoding) | q=a+b for the value a b; a/b+c becomes a%2Fb%2Bc | Same unreserved set, but a space becomes + rather than %20 | UTF-8 then percent-encoded | Building a query string or an x-www-form-urlencoded body |
Frequently asked questions
- What is the difference between encodeURI and encodeURIComponent?
- Exactly eleven characters. encodeURI leaves # $ & + , / : ; = ? @ alone; encodeURIComponent escapes all of them. That list is the RFC 3986 reserved set, and it tells you what each function assumes. encodeURI thinks you handed it a complete URL whose delimiters must keep working, so it only escapes things that could never be structure — spaces, non-ASCII, control characters. encodeURIComponent thinks you handed it a single value that must not be able to introduce any delimiter, so it escapes everything reserved. On the string a b/c?d=café+e&f#g~h*i(j) the first returns a%20b/c?d=caf%C3%A9+e&f#g~h*i(j) and the second returns a%20b%2Fc%3Fd%3Dcaf%C3%A9%2Be%26f%23g~h*i(j). Use encodeURIComponent for each path segment and each query value, which is nearly always what you want. Use encodeURI only on a whole URL you assembled yourself and already trust — running it on user input does not make that input safe, because it deliberately preserves the characters an injected value would need.
- Should a space be %20 or a plus sign?
- %20 is always correct; a plus sign is correct only in one specific context. RFC 3986 percent-encoding has a single representation for a space, %20, and it is valid in the path, the query and the fragment alike. The plus sign as a space comes from application/x-www-form-urlencoded, the older serialisation that HTML forms use, where spaces become plus signs and a literal plus must be written %2B. Browsers apply that when submitting a GET form, which is why real query strings contain both conventions. The practical hazard is on the decoding side: decodeURIComponent("a+b") returns a+b with the plus untouched, because it implements RFC 3986 and knows nothing about forms, whereas a form-aware parser returns a b. Do not decide case by case. Build query strings with URLSearchParams and read them back with URLSearchParams, so the same rules apply in both directions. In a path segment, always use %20 — a plus there is a literal plus character and nothing else.
- Why do I see %2520 in my URLs?
- Because something encoded a string that was already encoded. The percent sign is itself reserved, so it becomes %25 when escaped. Take a b, encode it to a%20b, then encode that result: the percent turns into %25 and you get a%2520b. Do it once more and you get a%252520b. Nothing errors out, the string just grows by three characters per round and needs one extra decoding pass to read. In real systems this happens when several layers each politely encode what they were handed — a client, then a gateway, then a framework rendering into a template. The symptom is a literal %20 showing up where a space should be, or a 404 on a path that looks right. The fix is architectural: nominate exactly one layer that owns encoding, encode there, and move raw values everywhere else. Avoid decoding in a loop until no percent remains, because that is its own vulnerability — repeated decoding can turn %252e%252e into .. and reopen a path traversal that a single decode had contained.
- Do I need to encode non-English characters in a URL?
- Yes, and the encoding runs through UTF-8 first. Percent-encoding works on bytes, so a character has to become bytes before it can be escaped, and the modern rule is to encode as UTF-8 and then write one percent-escape per byte. é is two bytes, c3 a9, and becomes %C3%A9. The euro sign is three bytes and becomes %E2%82%AC, nine characters for one symbol. An emoji at U+1F600 is four bytes and becomes %F0%9F%98%80. This matters for storage sizes and for any length limit you enforce, since one character can cost twelve. It also explains why the deprecated escape function corrupts data: it emits Latin-1, turning é into the single byte %E9, which a UTF-8 decoder rejects as invalid — and the breakage only appears on your accented records. The host part is the exception: domain names are not percent-encoded at all but converted with Punycode, so münchen.de becomes xn--mnchen-3ya.de while the path and query beside it use ordinary UTF-8 percent-encoding.
- Is percent-encoding enough to make user input safe?
- No, because encoding is contextual and a URL is only one of the contexts a value passes through. encodeURIComponent stops a value from breaking out of its URL component: a slash becomes %2F so it cannot start a new path segment, an ampersand becomes %26 so it cannot start a new parameter. That is genuine and important. It does nothing about the next hop. The same value placed into HTML needs HTML escaping, placed into a SQL statement needs a parameterised query, placed into a shell command needs argument-level quoting, and placed into a JavaScript string literal needs its own escaping. Encoding for the wrong context is not partial protection, it is no protection. Two additional cautions: encodeURI is not an input sanitiser, since it deliberately preserves the reserved characters an attacker would use, so never run it on untrusted data as a safety measure; and decoding repeatedly until no percent sign remains can reconstruct sequences a single decode had neutralised, notably turning %252e%252e back into a path traversal.
- How do internationalised domain names fit into this?
- They do not use percent-encoding at all. DNS labels are limited to letters, digits and hyphens, so a domain containing anything else is converted with Punycode, defined in RFC 3492 and governed by the IDNA specifications. münchen.de becomes xn--mnchen-3ya.de, bücher.example becomes xn--bcher-kva.example, and a Japanese domain such as the two characters for Japan followed by .jp becomes xn--wgv71a.jp. The xn-- prefix flags the label as encoded, and the rest holds the ASCII characters followed by instructions for putting the others back. You can see both systems at once by parsing a URL like https://münchen.de/straße?q=über alles: the result is https://xn--mnchen-3ya.de/stra%C3%9Fe?q=%C3%BCber%20alles, with Punycode on the host and UTF-8 percent-encoding everywhere after it. Practically, this means you should never percent-encode a hostname, and you should compare hostnames in their Punycode form. It is also why homograph attacks are handled by browser display policy rather than by encoding — the two names are genuinely distinct labels that merely look alike.
Articles you may find interesting
All guides →Related tools
Sources
- IETF — RFC 3986, Uniform Resource Identifier (URI): Generic Syntax
- WHATWG — URL Standard
- WHATWG — HTML Standard, URL-encoded form data
- IETF — RFC 3492, Punycode: A Bootstring encoding of Unicode for IDNA
- IETF — RFC 5890, Internationalized Domain Names for Applications (IDNA): Definitions and Document Framework
- MDN Web Docs — encodeURIComponent()
Spotted a mistake in this article?