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.
Where the character sits changes the answer
Every measurement above puts the character in the middle of apple — between the
two ps. Move it and the answers move with it, but not by the same amount. The
five positions below were measured on 15 November 2026 in Python 3.12.3 (its idna
codec) and Node 24.22.1 (new URL(), ICU 78.3, Unicode 17.0): the start of the
first label, the end of the first label, inside the TLD, the end of the TLD, and the start of
the second label. The Python column was checked against the middle-position values on this
page for the two characters the implementations disagree on, and it matches.
| Position of the character | U+200B zero width space | U+FF41 fullwidth a | U+3002 ideographic full stop | U+3164 hangul filler | U+00A0 no-break space |
|---|---|---|---|---|---|
| Start of first label | apple.com | aapple.com | Python: error, label empty or too long; Node: .apple.com | Python: xn--apple-ufv.com; Node: apple.com | Python: " apple.com"; Node: error |
| End of first label | apple.com | applea.com | Python: error, label empty or too long; Node: apple..com | Python: xn--apple-zfv.com; Node: apple.com | Python: "apple .com"; Node: error |
| Inside the TLD | apple.com | apple.caom | apple.c.om | Python: apple.xn--com-0do; Node: apple.com | Python: "apple.c om"; Node: error |
| End of the TLD | apple.com | apple.coma | apple.com. | Python: apple.xn--com-2do; Node: apple.com | Python: "apple.com "; Node: error |
| Start of second label | apple.com | apple.acom | Python: error, label empty or too long; Node: apple..com | Python: apple.xn--com-zdo; Node: apple.com | Python: "apple. com"; Node: error |
Read the table column by column, because each character has its own rule about position.
The zero-width space is position-proof: it is deleted in all five positions, in both
implementations, so no placement of it makes a new domain. The fullwidth a is
equally steady in the other direction: it is translated wherever it sits, and the
a it leaves behind lands exactly where the character was — at the front, at the
end, inside the TLD, or after the dot.
The ideographic full stop is the one where position is the whole story. Inside the TLD it
splits com into two labels, apple.c.om, and at the end of the TLD
it leaves a trailing dot, apple.com. — both implementations agree on those two.
Where it would leave an empty label, they part: Python's idna codec raises
UnicodeError ("label empty or too long"), while Node's URL parser returns the
hostname with the empty label still in it — .apple.com or
apple..com. A check that compares hostnames as strings will treat those as
different domains; a resolver will not resolve either. The same split is visible in the
no-break space column, where Python keeps a literal space in every position and Node refuses
the domain in every position.
One row of this table also corrects a row of the first one. That table records Node 22.22.2
refusing ap + U+3164 + ple.com; Node 24.22.1 deletes the filler and
returns apple.com, the same answer Chromium gave — re-measured in the middle
position as well as in all five positions above. The Hangul filler is now a two-answer
character (Python encodes, Node and Chromium delete) rather than a three-answer one, and the
answer that moved is the refusal. The disagreement is version-dependent, not fixed, so
re-check the Node column before quoting it.
To run the position test yourself, the Python column is one short loop — change the escape to the character you want to test:
# Python 3 — change the escape to the character you want to test
c = "\u200b"
for host in (c + "apple.com", "apple" + c + ".com", "apple.c" + c + "om",
"apple.com" + c, "apple." + c + "com"):
try:
print(host.encode("idna").decode())
except UnicodeError as e:
print("error:", e)
The Node column is the same five strings through new URL("http://" + host +
"/").hostname, and the browser column is the tool at the top of this page — paste the
string with the character in it and read what your own browser makes of it. What the three
disagree on is the point: a domain with one of these characters in it does not have a single
correct answer, and where the character sits decides which of the answers you get. The
comparison that stays honest across positions is on the page that
compares two texts that look identical.
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. Position table added 2026-11-15, measured in Python 3.12.3 and
Node 24.22.1 (ICU 78.3, Unicode 17.0); the Node 24 column deletes the Hangul filler that
Node 22.22.2 refused.
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.