Punycode: your invisible character is either encoded, translated or deleted
Short answer: punycode is the encoding that lets a domain name
written in any script travel as ASCII, and the usual assumption is that it encodes whatever you
typed. Measured across eleven characters in three implementations, it does four
different things instead. A look-alike letter is encoded: apаple.com
comes out as xn--apple-5ve.com. An invisible character is deleted:
ap + U+200B + ple.com and apple.com come out as
the same domain, with no error. A full-width or ideographic character is
translated into a different ASCII character: apaple.com becomes
apaple.com and ap。ple.com becomes ap.ple.com. And two
characters get three different answers from three implementations — Python
encodes one of them, Node refuses it, and Chromium deletes it. The gap between the middle two is
the one that matters: both are quiet, but one removes a character and the other replaces it.
—
Type or paste a hostname above.
What the mapping step does to a character
Before anything is encoded, the domain goes through a mapping step — the one the URL
standard points at, IDNA with its UTS #46 tables. That step is where a character is rewritten,
dropped or refused, and it runs before punycode does its work. So the honest question is not what
punycode does with your character; it is what is left of your character by the time punycode sees
it. Below, the same input — ap, the character, ple.com — is put
through all three implementations.
| Character | Python 3.13 idna | Node 22.22.2 URL | Chromium 153 | What happened |
|---|---|---|---|---|
| U+0430 Cyrillic а | xn--apple-5ve.com | xn--apple-5ve.com | xn--apple-5ve.com | encoded |
| U+03BF Greek ο | xn--apple-sce.com | xn--apple-sce.com | xn--apple-sce.com | encoded |
| U+200B zero width space | apple.com | apple.com | apple.com | deleted |
| U+00AD soft hyphen | apple.com | apple.com | apple.com | deleted |
| U+FEFF byte order mark | apple.com | apple.com | apple.com | deleted |
| U+2060 word joiner | apple.com | apple.com | apple.com | deleted |
| U+FF41 fullwidth a | apaple.com | apaple.com | apaple.com | translated to a |
| U+3002 ideographic full stop | ap.ple.com | ap.ple.com | ap.ple.com | translated to . |
| U+FF0E fullwidth full stop | ap.ple.com | ap.ple.com | ap.ple.com | translated to . |
| U+3164 hangul filler | xn--apple-wfv.com | refused | apple.com | three answers |
| U+00A0 no-break space | ap ple.com | refused | ap%20ple.com | three answers |
Nine of the eleven rows are the same in every column, which is what you would expect from a
standard. The other two are not, and they are the reason a domain that resolves on your machine can
fail on a server. "Refused" means the URL parser threw instead of returning a hostname —
measured in Node, new URL('http://apㅤple.com/') and
new URL('http://ap ple.com/') both raise a TypeError, while the same
two strings are accepted by the browser and by Python, which return three different hostnames
between them.
Same result, different mechanism
Four of the rows above end at apple.com, and the honest reading of that is not that
four characters behave the same way. It is that four characters were removed and one was replaced,
and the input happened to hide the difference: with the character sitting between the two
ps, deleting it leaves apple, and so does a translation that produces an
a.
Move the character one letter to the left and the two mechanisms separate. Put it between
a and b instead, so that a deleted character would leave
ab.com and a translated one would leave its replacement behind:
Character, in a<char>b.com | All three implementations | RFC 3454 B.1 "mapped to nothing" | Mechanism |
|---|---|---|---|
| U+200B zero width space | ab.com | yes | deleted — contributes nothing |
| U+00AD soft hyphen | ab.com | yes | deleted — contributes nothing |
| U+FEFF byte order mark | ab.com | yes | deleted — contributes nothing |
| U+2060 word joiner | ab.com | yes | deleted — contributes nothing |
| U+FF41 fullwidth a | aab.com | no | translated — contributes an a |
| U+3002 ideographic full stop | a.b.com | no | translated — contributes a . |
| U+FF0E fullwidth full stop | a.b.com | no | translated — contributes a . |
Read the middle column as the proof. ab.com has two letters in it, so the character
that was typed contributed nothing at all — it was dropped and the letters on either side
closed up. aab.com and a.b.com each carry one more character than the
input had letters, and the extra one is a real ASCII character the mapping step put there. The
third column reaches the same split a different way: it is the standard's own table of characters
mapped to nothing, not something measured here, and the four rows it marks are exactly the four
rows that lose their character. Two independent lines agree, which is why the two groups are not
the same thing even when they land on the same domain.
One of the three translations does not come from the usual place, and it is worth knowing which.
Normalising U+FF41 and U+FF0E under NFKC gives a and . respectively, so
those two are the ordinary compatibility mapping. U+3002 is unchanged under all four normalisation
forms — measured, NFC, NFD, NFKC and NFKD all leave it exactly as it is — and the three
implementations still turn it into a full stop. That translation is the domain layer's own rule for
what counts as a label separator rather than a Unicode normalisation, which is why it survives a
step that is supposed to be the one rewriting characters.
The deleted ones are the ones that cannot be seen
Four of the eleven characters are the characters this site is about, and they are the four that vanish. A zero-width space, a soft hyphen, a byte order mark and a word joiner are all dropped before encoding, in every implementation, silently. There is no warning, no placeholder and no record of the removal in the output: the hostname that comes back is a perfectly ordinary domain that already exists.
That has a consequence worth stating plainly. ap + U+200B + ple.com is
not a domain that resembles apple.com; it is apple.com once it
reaches the resolver, in all three implementations measured. So the character does not hide
anything and does not disguise anything. What it does is disappear, which means a domain carrying it
is not a different domain at all — and a check written to spot a look-alike domain is looking
for the wrong thing when it is handed this one, because there is nothing left to spot.
The look-alikes are the ones that get encoded
The rows that end in xn-- are the ones punycode is normally described as handling, and
the description is accurate. A Cyrillic а or a Greek ο is a letter, it survives the mapping step,
and it is encoded: the address bar then carries ASCII that does not read like the name you typed.
All three implementations agree on both strings, to the character.
The practical difference from the deleted group is where the evidence ends up. A deleted character leaves nothing, so the browser has nothing to display and shows an ordinary domain. An encoded one leaves a string, so the browser has to decide what to show: the Unicode name or the encoding. Those two rows are the case where the decision is visible — what you read is not what the request carried, and unlike the invisible characters this is a real substitution rather than a deletion. It is also the reason the two are usually discussed together and should not be: one is a character that is gone, the other is a character that is doing something.
A homograph attack and a phishing URL both lean on the same fact this page has been measuring — that the hostname a person reads and the hostname a resolver uses can be two different strings — and the characters that are removed from a hostname rather than substituted in it are on the invisible character list.
Two characters, three implementations, three answers
The last two rows of the first table are not a curiosity, because they are the two that decide whether a domain works at all. A Hangul filler and a no-break space are both perfectly ordinary characters in text; in a hostname they are split three ways.
A Hangul filler is encoded by Python to xn--apple-wfv.com, refused by Node's URL
parser, and deleted by Chromium, which returns apple.com. A no-break space is left as a
literal space by Python, refused by Node, and written as %20 by Chromium. So a hostname
containing either one does not have a single correct answer to compare against: it has three, and
which one you get depends on which of the three ran. The mapping step is a standard, and the
standard is implemented to three different extents — the browser is the most permissive of the
three, and the two runtimes that would have to agree in a server-side check are the two that
refuse.
Why this is not the same as the character being in the path
A domain name is not the only place in a URL a character can go, and the two positions go opposite ways. In the hostname, the invisible characters measured on this page are deleted, so the domain collapses onto one that already exists. In the path they are kept, and the URL serialiser writes each one out as a percent escape, so a blank profile URL ends up carrying nine visible characters instead of none.
Deletion in one position and preservation in the other is why the two questions have different answers, and it is what makes the domain case the sharper one. A path that keeps your character is at least honest about the fact that there is a character there. A hostname that drops it does not leave a trace to be honest about: the string you typed and the string that resolves are the same string, and the character is not part of either. The measured comparison between a username and a profile URL is on the invisible username page; what is on this page is the other half, where the character is not preserved and not encoded but removed.
What this page does not tell you
Whether any particular domain is registered, or what a browser will do with one that is. The measurements above are of the mapping and encoding steps, run on eleven characters, and the input is a made-up domain throughout. A registrar, a certificate authority and a browser's own display rules each add their own layer, and those layers are not measured here.
The set of eleven is the set measured, not a complete list of the characters that map to nothing.
RFC 3454's table B.1 is much longer than the four rows above, and a different set moves the
counts without moving the direction of any result. The implementations are single builds as well.
Python's built-in idna codec follows the older IDNA2003 rules rather than the UTS #46
tables the URL standard points at, which is part of why it is the odd one out on the Hangul filler
row; Node 22.22.2 carries ICU 78.2 with Unicode 17.0; Chromium 153 has its own implementation of the
same standard. Categories and mechanisms will hold across versions, but the exact hostnames in the
first table are a measurement of these three builds.
Measured 2026-10-07 with Python 3.13.14 (its idna codec and
stringprep), Node 22.22.2 (new URL(), ICU 78.2, Unicode 17.0) and
Chromium 153 via Playwright.
Related: the full list of invisible characters,
why a blank username still has to be unique,
the zero-width space remover,
copy a blank character and see what it is,
the four invisible characters that are real letters,
tag characters that hide a whole ASCII message,
how to find hidden characters in a file, and
comparing two texts that look identical.