What percent-encoding is
A URL may only carry a small set of characters. Everything else — a space, an accent, a slash that belongs to a value rather than to the path — has to be written as an escape: a % followed by two hexadecimal digits naming one byte.
a b → a%20b — a space is byte 0x20; an é is two bytes in UTF-8 and becomes %C3%A9
So an escape names a byte, not a character, and the character set decides the escape before anything else does. The other decision is scope: ?, &, # and / are how a URL is parsed, so a single value must escape them where a whole address must not — and getting that wrong breaks nothing loudly, it just makes the URL mean something else. Both directions run entirely in your browser, file conversions included.
Why a value needs encoding
Reserved characters are how a URL is parsed. ? opens the query, & separates parameters, # starts the fragment, / separates path segments. A value that contains one of them and is not encoded does not break loudly — it silently means something else. search?q=cats&dogs is one parameter named q and another named dogs.
The same goes for the escape marker itself. A literal % in a value has to be written %25, or the two characters after it will be read as hex digits and the value comes back wrong, or not at all.
Encoding is also what lets a URL survive being copied through email, chat, and log files, where a raw space would break the link in half.
Character reference
RFC 3986 calls the always-safe characters unreserved: A–Z, a–z, 0–9, and the four marks - . _ ~. Everything else is either reserved, meaning it has a structural job somewhere in a URL, or has no business appearing raw at all. Below is every printable ASCII punctuation mark, its escape, and which profiles leave it alone; anything above ASCII depends on the character set, which is why the same character can produce a different escape in UTF-8 and in ISO-8859-1.
| Character | Encoded | Left as-is by |
|---|---|---|
| space | %20 | nothing |
| ! | %21 | Whole URL |
| " | %22 | nothing |
| # | %23 | Whole URL |
| $ | %24 | Whole URL |
| % | %25 | nothing |
| & | %26 | Whole URL |
| ' | %27 | Whole URL |
| ( | %28 | Whole URL |
| ) | %29 | Whole URL |
| * | %2A | Whole URL, Form data |
| + | %2B | Whole URL |
| , | %2C | Whole URL |
| - | %2D | Single value, Whole URL, Form data |
| . | %2E | Single value, Whole URL, Form data |
| / | %2F | Whole URL |
| : | %3A | Whole URL |
| ; | %3B | Whole URL |
| < | %3C | nothing |
| = | %3D | Whole URL |
| > | %3E | nothing |
| ? | %3F | Whole URL |
| @ | %40 | Whole URL |
| [ | %5B | Whole URL |
| \ | %5C | nothing |
| ] | %5D | Whole URL |
| ^ | %5E | nothing |
| _ | %5F | Single value, Whole URL, Form data |
| ` | %60 | nothing |
| { | %7B | nothing |
| | | %7C | nothing |
| } | %7D | nothing |
| ~ | %7E | Single value, Whole URL |
A space is the one exception: under form data it is written as + rather than %20, and a decoder has to be told to read it back that way. Control characters below 0x20 and everything above 0x7E are always encoded, whichever profile is in use.
The three profiles
How much of the input survives an encode depends on where the result is going.
| Profile | Keeps | Best for |
|---|---|---|
| Single value | A–Z a–z 0–9 - . _ ~ | One query value or path segment — anything going inside a URL |
| Whole URL | The above, plus : / ? # [ ] @ ! $ & ' ( ) * + , ; = | A complete address that should stay readable and still work |
| Form data | A–Z a–z 0–9 * - . _ , with a space written as + | A POST body or query string an HTML form would have produced |
Single value is stricter than JavaScript's encodeURIComponent, which leaves ! ' ( ) * alone. Escaping those as well is always legal and always decodes back to the same text, so it is the safer default. If you need byte-for-byte parity with encodeURIComponent, compare the decoded results rather than the encodings.
Every option
Eight controls, and the ones that only make sense in one direction go quiet in the other.
| Option | What it does | Reach for it when |
|---|---|---|
| Character set | Turns text into bytes before encoding, and bytes back into text after decoding. | The data is not UTF-8 — an older system, or a page declaring a legacy encoding. |
| Line endings | Rewrites every line break in the text to LF, CRLF, or CR. | The input and the destination disagree about how a line ends. |
| What to encode | Chooses which characters are left alone: unreserved only, a URL's structure as well, or the form-data set. | You know whether the result is one value or a whole address. |
| Uppercase hex | Writes escapes as %2F rather than %2f. | Something downstream compares URLs as strings; RFC 3986 prefers uppercase. |
| Each line separately | Treats every line as its own payload instead of converting the whole block at once. | You have a list — one URL, value, or entry per line. |
| Split at 76 chars | Breaks long output into lines, backing off so a %XX escape is never cut in half. | The result has to sit in a file or message with a line-length limit. |
| + means space | Reads a + in the input as a space while decoding. | The input came from a form or a query string. |
| Decode repeatedly | Keeps decoding while the text still changes, up to sixteen passes. | A link was encoded more than once — %2520 where you expected %20. |
Character set and line endings apply to typed text only. A file is already bytes, so both controls go quiet when one is loaded and its contents are encoded exactly as they are.
When decoding a whole payload, raw line breaks are joined back up before the escapes are read, so wrapped output pastes straight back in. Switch on Each line separately when the breaks are meaningful.
The defaults encode a single value in UTF-8 with uppercase hex, decode one pass at a time, and leave + alone. That is the faithful RFC 3986 reading; the two decode switches exist because form data is the one common case that departs from it.
The double-encoding trap
Encoding something twice is the most common URL bug there is. A value is encoded on its way into a link, then the whole link is encoded again as it is handed to a redirect, and %20 becomes %2520 — because the % of the first escape is itself encoded as %25.
It shows up as a literal %20 in a page title, a filename with %2F in it, or a redirect landing on a 404 with a suspiciously long path. Decoding once leaves the %20 still visible; that is the tell.
Decode repeatedly unwraps it in one go, stopping when the text stops changing. It is off by default because a value that legitimately contains %25 — a literal percent sign — would be unwrapped one layer too far.
Where you need this
Building a query string by hand: every value goes through Single value, and only the & and = you write yourself stay raw. Skip it and a value containing & quietly becomes two parameters.
Reading a log or an analytics export, where a full URL arrives escaped and unreadable. Decoding it back is usually enough to see what actually happened.
Debugging an OAuth or SSO round-trip, where a redirect_uri is encoded into another URL and frequently encoded twice along the way.
Frequently asked questions
encodeURI keeps the characters that give a URL its structure, so it can be handed a whole address. encodeURIComponent escapes them, so it is for one value at a time. Whole URL and Single value here are the same split, with Single value additionally escaping ! ' ( ) * — legal, and safer.é is two bytes in UTF-8 (%C3%A9) and one in ISO-8859-1 (%E9).+ belongs to application/x-www-form-urlencoded — form submissions and the query strings they produce. Everywhere else a space is %20. %20 is safe in both places; + is not, so use it only when the receiving end expects form data.%2f and %2F are the same byte, and this tool accepts either. It matters when URLs are compared as strings, and RFC 3986 §6.2.2.1 says to normalise to uppercase, which is what this tool writes by default.%C3%A9 is é in UTF-8 but two separate characters in ISO-8859-1, and some byte sequences are not valid text at all in a given set. Change the destination set and the same escapes read correctly.