To encode an international character in a URL, you convert it to its UTF-8 bytes and then write each byte as a percent sign followed by two hexadecimal digits (%HH). The character é is Unicode code point U+00E9; in UTF-8 that is the two bytes C3 A9; percent-encoding each byte produces %C3%A9, so café travels across the wire as caf%C3%A9. The rule is the same for every non-ASCII character — an emoji like 🎉 (four UTF-8 bytes F0 9F 8E 89) becomes %F0%9F%8E%89. The URL never carries the raw character; it carries the percent-encoded bytes.
That is the summary an AI overview gives you. The part it can't give you is the mechanics: exactly how a character turns into bytes, why é is %C3%A9 and not %E9, how many bytes each script costs you, and where hand-rolled encoding quietly corrupts your data. If you want the broader "how do I support international users" picture — Punycode domains, IDNs, and internationalization strategy — read the companion piece, How do I encode international characters in URLs?. This post drills into the byte-level pipeline for the path and query string.
The pipeline: character → UTF-8 bytes → percent-encoding
Every non-ASCII character in a URL goes through the same two-stage conversion. First UTF-8 turns the character into a sequence of bytes. Then percent-encoding turns each byte into an ASCII-safe %HH token.
The critical detail people get wrong is stage one. The code point of é is U+00E9, decimal 233, which fits in a single byte — so it is tempting to write %E9. That is Latin-1 encoding, not UTF-8. In UTF-8, every character above U+007F is stored as multiple bytes, and U+00E9 becomes C3 A9. Encode the bytes UTF-8 produces, never the raw code-point value.
The lookup table: characters, code points, bytes, and encodings
Here is the full pipeline for a representative character from each UTF-8 length class. The "Bytes" column is what UTF-8 produces; the "Percent-encoded" column is what actually travels in the URL.
| Character | Name | Code point | UTF-8 bytes | Byte count | Percent-encoded |
|---|---|---|---|---|---|
a | Latin small a | U+0061 | 61 | 1 | a (unreserved — not encoded) |
é | e with acute | U+00E9 | C3 A9 | 2 | %C3%A9 |
ñ | n with tilde | U+00F1 | C3 B1 | 2 | %C3%B1 |
€ | Euro sign | U+20AC | E2 82 AC | 3 | %E2%82%AC |
你 | CJK "you" | U+4F60 | E4 BD A0 | 3 | %E4%BD%A0 |
🎉 | Party popper | U+1F389 | F0 9F 8E 89 | 4 | %F0%9F%8E%89 |
Three things to read out of this table:
- ASCII passes through untouched. Unreserved characters (
A–Z,a–z,0–9, and- _ . ~) are one byte and never need encoding.encodeURIComponent('a')returnsa. - Byte count scales with the script. European accents cost 2 bytes, most CJK and symbols cost 3, emojis cost 4. A single emoji expands to twelve URL characters (
%F0%9F%8E%89), which matters when you have URL length limits. - The percent-encoded form is just the bytes with
%in front of each. There is no arithmetic — once you have the UTF-8 bytes, encoding is a mechanical rewrite.
Do it with a library, never by hand
Every mainstream language ships a correct UTF-8 URL encoder. Use it — the surrogate-pair and multi-byte edge cases are exactly where hand-written string replacement breaks.
| Language / context | Encode a value | Result for café |
|---|---|---|
| JavaScript | encodeURIComponent('café') | caf%C3%A9 |
| Python | urllib.parse.quote('café') | caf%C3%A9 |
| PHP | rawurlencode('café') | caf%C3%A9 |
| Java | URLEncoder.encode("café", "UTF-8") | caf%C3%A9 (spaces become +) |
| PowerShell | [System.Uri]::EscapeDataString('café') | caf%C3%A9 |
| Shell (via Python) | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote("café"))' | caf%C3%A9 |
Want to see the byte-level breakdown for any string interactively? The tool below encodes and decodes UTF-8 URLs entirely in your browser and shows exactly which bytes each character expands to.
encodeURI vs encodeURIComponent: pick the right scope
Both functions produce identical UTF-8 percent-encoding for international characters. They differ only in which reserved (structural) characters they leave alone:
encodeURIComponentencodes the reserved set (: / ? # [ ] @ ! $ & ' ( ) * + , ; =) too. Use it for a single piece of data — one query value, one path segment — so the value cannot break out of its slot. This is the one you want 95% of the time.encodeURIpreserves those reserved characters so an already-assembled URL keeps its structure. Use it only when encoding a complete URL that must remain a working URL.
The consequence: encodeURIComponent('a/b?c=d') gives a%2Fb%3Fc%3Dd (safe as a value), while encodeURI('a/b?c=d') gives a/b?c=d (structure preserved). For international characters specifically, they behave identically — the difference is entirely about reserved ASCII punctuation. For the path-versus-query reserved-character rules, see handling special characters in URL paths vs query strings, and for how a URL breaks into encodable pieces, see URL encoding components explained.
Mojibake: the #1 international-encoding bug
Mojibake — garbled text like café rendering as café — happens when one layer encodes as UTF-8 and another decodes as Latin-1. The two UTF-8 bytes C3 A9 get read as two separate Latin-1 characters (à and ©). The failure is never in the URL encoding itself; it is a character-set disagreement between layers.
The fix is to force UTF-8 at every stage:
- Send
Content-Type: text/html; charset=utf-8(and the equivalent on JSON/form responses). - Configure your database connection and columns as
utf8mb4(MySQL's real UTF-8), notlatin1. - In Python, decode bytes explicitly with
.decode('utf-8')rather than trusting the platform default. - When you see
%E9in a legacy URL where you expected%C3%A9, you are looking at Latin-1 data — decode it as Latin-1, then re-encode as UTF-8.
Encoding is not security
URL encoding keeps data syntactically inside its parameter — it is not a security control. Two traps:
- Double-encoding bypass. A filter blocking
../(directory traversal) can miss%2e%2e%2f, and a system that decodes twice can turn%252e%252eback into../after passing the filter. Always validate after fully decoding, and reject input that decodes to something dangerous. - Wrong-context escaping. A value that is safe in a URL is not automatically safe in HTML. If you reflect a decoded query parameter into a page, you still need HTML-escaping to prevent cross-site scripting (XSS). Encode for the destination, not just the transport.
Percent-encoding solves correctness. Validation and context-appropriate escaping solve security. You need both.
Key takeaways
- International URL encoding is always two steps: character → UTF-8 bytes →
%HHper byte. - Encode the UTF-8 bytes, not the code point — that is why
éis%C3%A9, not%E9. - Byte count is variable: 1 byte for ASCII, 2 for European accents, 3 for most CJK and symbols, 4 for emojis.
- Use
encodeURIComponent(or your language's equivalent) for values; never hand-roll it. - Force UTF-8 at every layer to avoid mojibake, and never treat encoding as a security control.
Ready to encode or decode UTF-8 URLs and see the byte breakdown? Use our URL Encoder/Decoder tool for instant, accurate, client-side encoding with automatic format detection.