A URL is limited to a small set of ASCII characters. Anything else is represented as a percent sign followed by the two hex digits of its byte value. The rules come from RFC 3986, which divides characters into three groups.
| Group | Characters | Treatment |
|---|---|---|
| Unreserved | A–Z, a–z, 0–9, hyphen, full stop, underscore, tilde | Never need encoding |
| Reserved | colon, slash, question mark, hash, square brackets, at sign, exclamation, dollar, ampersand, apostrophe, parentheses, asterisk, plus, comma, semicolon, equals | Structural — must be encoded when used as data |
| Everything else | Spaces, accented letters, emoji, all other scripts | Always encoded |
The reserved group is where the thinking happens. A slash separating path segments is doing its job; a slash inside a value is data and must become %2F, or the server will read it as a boundary.
Non-ASCII text is converted to UTF-8 first, then every byte is encoded separately. One character can therefore produce two, three or four escapes:
| Character | Code point | UTF-8 bytes | Encoded |
|---|---|---|---|
| Space | U+0020 | 20 | %20 |
| é | U+00E9 | C3 A9 | %C3%A9 |
| Euro sign | U+20AC | E2 82 AC | %E2%82%AC |
| 中 | U+4E2D | E4 B8 AD | %E4%B8%AD |
This is why a URL containing a few words of Chinese or Arabic becomes enormously long, and it is also why encoding in the wrong character set produces mojibake rather than an error — a byte sequence from a legacy encoding is still valid percent-encoding, just meaningless as UTF-8.
Both mean a space, in different contexts, and confusing them is the single most common URL bug.
| Context | Space becomes |
|---|---|
| Path segment | %20 — a plus here is a literal plus |
| Query string, form-encoded | Plus sign, historically; %20 also accepted |
| Fragment | %20 |
The consequence: a literal plus in a query value must be written as %2B. Searching for the string C++ works only if it is sent as C%2B%2B — send the plus signs raw and the server receives C followed by two spaces. The same trap catches phone numbers in international format and any value that legitimately contains a plus.
Two encoding functions exist because there are two jobs, and using the wrong one is the second most common bug.
| Function | Leaves alone | Use for |
|---|---|---|
| Whole-URL encoding | All structural delimiters — slash, question mark, hash, ampersand, equals, colon | A complete URL you want made safe without breaking it |
| Component encoding | Only unreserved characters plus a handful of legacy exceptions | One value going into a path segment or query parameter |
Encode a query value with whole-URL encoding and any ampersand or equals sign inside it survives — which means the value silently splits into extra parameters. Where that value came from user input, this is not merely a bug, it is parameter injection.
Two footnotes. Component encoding, as implemented in browsers, does not encode the exclamation mark, apostrophe, parentheses or asterisk, although RFC 3986 treats them as reserved sub-delimiters; strict compliance requires handling those separately. And the very old escape function, still found in legacy code, emits a non-standard form for non-ASCII characters that is not valid percent-encoding at all. It should never be used.
Percent-encoding is not idempotent. The percent sign is itself a character that needs encoding, so encoding an already-encoded string transforms every %20 into %2520:
| Pass | Result |
|---|---|
| Original | my file.pdf |
| Encoded once | my%20file.pdf |
| Encoded twice | my%2520file.pdf |
| Encoded three times | my%252520file.pdf |
A URL littered with %25 is the signature: something encoded a value that a framework had already encoded. The fix is to find which layer is doing it and remove one, not to decode twice at the other end — decoding twice will happily decode data that was meant to stay encoded, which is a security problem in its own right.
The same character is treated differently depending on where it sits. A hash sign starts the fragment, and the fragment is never sent to the server — it exists only in the browser, so a value containing a hash must be encoded or everything after it vanishes from the request. An encoded slash inside a path segment is a notorious deployment problem: %2F is semantically distinct from a slash, but many servers and proxies normalise or reject it, so identifiers that can contain slashes are better placed in the query string or base64url-encoded.
Host names are a different system entirely. Internationalised domains are not percent-encoded — they are converted by Punycode into an ASCII form beginning xn--. So the same accented letter is handled one way in the host and another way three characters later in the path. This also underlies homograph attacks, where characters from another script visually imitate a familiar domain, which is why browsers display the punycode form when a name mixes scripts suspiciously.
Percent-encoding makes a string safe to transport. It does nothing to make it safe to use. It does not prevent cross-site scripting, because the value is decoded before it reaches your template; it does not prevent SQL injection, which needs parameterised queries; and it does not prevent open redirects, since a fully encoded external URL decodes back to an external URL.
For input validation the ordering matters and is frequently got wrong: decode exactly once, canonicalise the result, and then validate. Validating before decoding is how encoded traversal sequences slip past filters, and decoding repeatedly until nothing changes reintroduces the same hole from the other direction.
Two practical constraints. The specification sets no maximum URL length, but around 2,000 characters is the safe working ceiling given older browsers, proxies and server defaults — and since encoding can triple the length of non-ASCII text, a URL that looks short can exceed it. And URLs appear in server logs, referrer headers and browser history, so sensitive values belong in a request body, not in a query string, regardless of how well they are encoded.
Just the parameter, in almost every case. Encode each value as you build the URL; never encode a URL you have already assembled unless you intend the delimiters to survive.
Double encoding. Something encoded a string that was already encoded — remove one of the two passes rather than decoding twice.
It was read as a space in the query string. Send it as %2B.
No. Encoding and decoding run in your browser, and nothing you paste is transmitted, logged or saved.
Every tool comes with a written guide, and every category is one click away.