Percent-encoding in URLs: why é becomes %C3%A9
koboshi · Co-founder
Try it yourself
Everything in this article runs live in the converter. Paste your own text and watch every notation update at once.
Open the Unicode converterOpen the Unicode converter and type the single character é. The Percent-encoded panel answers %C3%A9. Now look at the UTF-8 code units panel next to it: C3 A9. The first answer is the second answer with a percent sign glued in front of every byte. That observation explains almost everything about URL encoding. Percent-encoding is not a character encoding. It is a way to write arbitrary bytes using only printable ASCII, and UTF-8 is what decides which bytes you start with.
The rule: UTF-8 first, then percent signs
A URI is an ASCII string. RFC 3986 defines every URI as a sequence of characters from a small ASCII subset, so a character like é cannot appear in one at all. To carry it anyway, section 2.5 of the same RFC prescribes a two-step recipe: encode the character as UTF-8, then represent each resulting byte as % followed by two hexadecimal digits. The WHATWG URL Standard, which is what browsers actually implement, formalizes the identical procedure under the name UTF-8 percent-encode.
Because UTF-8 uses a variable number of bytes per character, the number of %XX groups tells you the byte count directly. Two groups for most Latin, Greek and Cyrillic letters, three for CJK ideographs, four for emoji:
é -> %C3%A9 (U+00E9, 2 bytes)
東京 -> %E6%9D%B1%E4%BA%AC (U+6771 U+4EAC, 3 bytes each)
😀 -> %F0%9F%98%80 (U+1F600, 4 bytes)Each %XX is one byte, not one character, which is why decoding has to reassemble bytes through UTF-8 before any characters reappear. If the byte-level mechanics feel unfamiliar, the companion piece on how UTF-8 turns code points into bytes walks through the bit patterns. One footnote: the hex digits are case-insensitive, so %c3%a9 and %C3%A9 are the same two bytes. Uppercase is the convention most encoders emit.
Which characters stay literal
RFC 3986 names a small set of characters that never need encoding, the unreserved set: A-Z a-z 0-9 and exactly four punctuation marks, - . _ ~. Two URIs that differ only in whether an unreserved character is percent-encoded are equivalent, and decoders must treat them the same, so %41 is simply an awkward spelling of A.
Everything else becomes percent-encoded bytes when it appears as data. The converter's Percent-encoded panel applies exactly this policy: letters, digits and - . _ ~ pass through, a space becomes %20, and the rest is encoded byte by byte:
hello world -> hello%20world
café -> caf%C3%A9
/+!~ -> %2F%2B%21~The last line is worth a second look. The reserved characters of RFC 3986, things like / ? & +, are allowed literally in a URL only when they play their syntactic role as separators. When one of them is part of your data, it has to be encoded, which is why a filename containing a slash can never survive as a raw path segment.
Why old links show %E9 instead
If you have ever met é encoded as a bare %E9, you have met a legacy single-byte encoder. E9 is the byte for é in Latin-1 and in Windows-1252, so web software from the pre-UTF-8 era percent-encoded one byte per character in whatever code page it happened to use. The history of those code pages is covered in ASCII vs Latin-1 vs Unicode. JavaScript still carries the fossil: the ancient escape() function, deprecated long ago and kept only for compatibility, returns %E9 for é and a dead %u6771%u4EAC syntax for 東京, which no modern decoder accepts.
Today that output is just broken. decodeURIComponent("%E9") throws URIError: URI malformed, because in UTF-8 the byte E9 is a lead byte that announces two continuation bytes, and they never arrive. The converter decodes percent escapes as UTF-8 for the same reason: feed its Percent-encoded panel a bare %E9 and you get back nothing, an incomplete sequence with no character to show.
Query strings and the plus sign
HTML forms submit their data as application/x-www-form-urlencoded, a variant with its own rules, defined in the WHATWG URL Standard. Everything except ASCII letters, digits and * - . _ gets percent-encoded, and a space is written as + (byte 0x2B) instead of %20. Note that tilde is on the encoding side of this fence: it stays literal under RFC 3986 but becomes %7E in form data.
new URLSearchParams({ q: "東京 駅" }).toString()
// "q=%E6%9D%B1%E4%BA%AC+%E9%A7%85"Form parsers replace + with a space before percent-decoding. Outside that context, + is a literal plus sign. So in the query of a submitted form, a+b means "a b", while in a path segment a+b means exactly what it says. Both + and %20 decode as space in a form-encoded query; only %20 means space everywhere else. Swapping those two contexts is the source of a steady stream of real bugs.
encodeURIComponent or encodeURI
JavaScript offers two encoders and the choice confuses people regularly. encodeURIComponent encodes everything except A-Z a-z 0-9 and - _ . ! ~ * ' ( ), a set the WHATWG standard explicitly notes matches its own component percent-encode set. Use it for each individual value you splice into a URL. encodeURI additionally leaves the structural characters alone (; / ? : @ & = + $ , #), so a complete URL passes through with its skeleton intact.
encodeURIComponent("東京 駅") // "%E6%9D%B1%E4%BA%AC%20%E9%A7%85"
encodeURIComponent("/+!~") // "%2F%2B%21~"
encodeURI("/search?q=東京 駅&page=1")
// "/search?q=%E6%9D%B1%E4%BA%AC%20%E9%A7%85&page=1"
decodeURIComponent("%C3%A9") // "é"
decodeURIComponent("%E9") // throws URIError: URI malformedThe working rule is simple. Build URLs from parts and run encodeURIComponent on each value. Never run it on a whole URL, since it would eat the ? and & separators. And never run encodeURI on a value that might contain & or +, since it would leave them to corrupt the query structure around them.
Percent-encoding is byte quoting, not character encoding. UTF-8 decides which bytes exist; the percent sign just makes them safe to print in ASCII.
Putting it to work
When a mangled URL lands in a bug report, the converter decodes it in both directions. Paste %C3%A9 into the main input and the Characters panel shows é; type é and the Percent-encoded panel shows %C3%A9 again. Three habits cover most situations:
- Count the
%XXgroups to read the byte length: two for accented Latin letters, three for CJK, four for emoji. A single group for a non-ASCII character means a legacy encoder was involved. - Before decoding a query value, decide whether
+should become a space. In form-encoded queries yes; everywhere else, no. - Build URLs with
encodeURIComponentper value, or withURLSearchParams, and leaveescape()to the history books.
Try it yourself
Everything in this article runs live in the converter. Paste your own text and watch every notation update at once.
Open the Unicode converter