The full stop and its look-alikes¶
Level: 201 · for anyone who has pasted a hostname, a version number or a command out of a document
One line: To a parser only U+002E FULL STOP is a dot. The characters drawn like one are different code points with different bytes: compatibility normalization turns three of them back into . and leaves the rest alone, the codec a hostname goes through reads a different five as dots, and a hex dump prints . for bytes that are not a dot at all.
import unicodedata
'a\N{ONE DOT LEADER}b'.split('.') # ['a․b'] <- one piece: not a dot
unicodedata.normalize('NFKC', '\N{ONE DOT LEADER}') # '.' <- NFKC folds this one back
unicodedata.normalize('NFKC', '\N{IDEOGRAPHIC FULL STOP}') # '。' <- and leaves this one alone
'a\N{IDEOGRAPHIC FULL STOP}b'.encode('idna') # b'a.b' <- which a hostname still reads as a dot
A parser does not look at a character; it compares a number. Every syntax that gives . a meaning — a file extension, a version, a hostname, a decimal point, a regex, a method call — means the one code point U+002E, written as the one byte 2e. Anything else is a different number, however it is drawn.
Confusables and scripts made that point about letters, where Cyrillic а is a different letter that a font draws like Latin a and no normalization form merges the two. Punctuation has the same problem with a twist: for some of the dot's look-alikes Unicode did declare them to be the full stop in other clothes, so the standard fix works on some rows and not on the rows beside them.
They arrive from ordinary places. Autocorrect turns three dots into … (U+2026). A Chinese or Japanese input method types 。 and .. Catalan writes l·l with a middle dot, the Greek ano teleia normalizes to that same middle dot, ⋅ is the dot product, and • comes along with a list pasted out of a document. And 'İ'.lower() leaves a dot behind as a combining mark on an ordinary i.
None of them contains the byte 2e. UTF-8 builds every multi-byte sequence out of bytes 80 and above (UTF-8 by hand has the table), so a program that scans bytes for the dot is exactly as blind as one that compares characters: it cannot cut a look-alike in half, and it cannot find one either.
In Python¶
The program lines the nine up against the real one, then asks three readers in the standard library the same question. str.split compares code points. NFKC applies what Unicode declared. The idna codec, which is what a str hostname goes through, holds a list of its own.
Verified output of the_full_stop_and_its_lookalikes_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. ONE DOT, AND NINE CHARACTERS DRAWN LIKE IT
------------------------------------------------------------------------
code point name UTF-8 in a word
U+002E FULL STOP 2e a.b
U+2024 ONE DOT LEADER e2 80 a4 a․b
U+2026 HORIZONTAL ELLIPSIS e2 80 a6 a…b
U+FF0E FULLWIDTH FULL STOP ef bc 8e a.b
U+3002 IDEOGRAPHIC FULL STOP e3 80 82 a。b
U+FF61 HALFWIDTH IDEOGRAPHIC FULL STOP ef bd a1 a。b
U+00B7 MIDDLE DOT c2 b7 a·b
U+0387 GREEK ANO TELEIA ce 87 a·b
U+22C5 DOT OPERATOR e2 8b 85 a⋅b
U+2022 BULLET e2 80 a2 a•b
look-alikes whose UTF-8 holds the byte 2e 0 of 9
Every one of them is two or three bytes, and none of those bytes is
2e. A multi-byte UTF-8 sequence is built only from bytes 80 and up, so
a program scanning for the dot's byte cannot find a look-alike, and
cannot cut one in half either. The Rust example checks that claim
against every scalar value there is.
2. WHAT UNICODE DECLARED ABOUT EACH ONE
------------------------------------------------------------------------
code point decomposition NFC NFKC
U+002E (none) same same
U+2024 <compat> 002E same U+002E
U+2026 <compat> 002E 002E 002E same U+002E x3
U+FF0E <wide> 002E same U+002E
U+3002 (none) same same
U+FF61 <narrow> 3002 same U+3002
U+00B7 (none) same same
U+0387 00B7 U+00B7 U+00B7
U+22C5 (none) same same
U+2022 (none) same same
NFKC turns into full stops U+2024 U+2026 U+FF0E
NFC changes at all U+0387
A decomposition in angle brackets is a COMPATIBILITY mapping: Unicode
saying this is the other character in different clothes, a leader dot
or a wide one, and NFKC applies it. The ideographic full stop has no
mapping at all. It ends sentences too, but it is a different
punctuation mark, so NFKC leaves it -- and folds its halfwidth form onto
IT, not onto '.'. A bare mapping with no brackets is CANONICAL, and
even NFC, the form usually called safe, applies it: GREEK ANO TELEIA
becomes MIDDLE DOT, which is still not a dot.
3. THREE READERS, THREE ANSWERS
------------------------------------------------------------------------
code point split on dot NFKC is dots .encode('idna')
U+002E 2 pieces True b'a.b'
U+2024 1 piece True b'a.b'
U+2026 1 piece True b'a...b'
U+FF0E 1 piece True b'a.b'
U+3002 1 piece False b'a.b'
U+FF61 1 piece False b'a.b'
U+00B7 1 piece False b'xn--ab-0ea'
U+0387 1 piece False b'xn--ab-0ea'
U+22C5 1 piece False b'xn--ab-9qv'
U+2022 1 piece False b'xn--ab-f3t'
str.split('.') finds a dot in U+002E
NFKC makes a dot of U+002E U+2024 U+2026 U+FF0E
.encode('idna') writes a 2e for U+002E U+2024 U+2026 U+FF0E U+3002 U+FF61
Three readers, three questions. split() compares code points, and
exactly one code point is 2E. NFKC asks what Unicode declared, which
is section 2. The idna codec holds a LIST: RFC 3490 section 3.1 names
four characters that must be recognised as the dots between labels,
U+002E U+3002 U+FF0E U+FF61, and every other row that comes out with
a 2e got it from the NFKC that nameprep runs inside each label.
4. THE ORDER OF THE READERS DECIDES
------------------------------------------------------------------------
s = '127' + IDEOGRAPHIC FULL STOP + '0.0.1'
s.split('.') ['127。0', '0', '1']
ipaddress.ip_address(s) ValueError
s.encode('idna') b'127.0.0.1'
ipaddress.ip_address(that, decoded) 127.0.0.1
'a...b'.encode('idna') UnicodeEncodeError
('a' + ELLIPSIS + 'b').encode('idna') b'a...b'
The same string is not an IP address before the idna codec and is one
after it. And the codec itself decides what a dot is twice: it splits
the name into labels on its four dots FIRST, and runs NFKC inside each
label AFTER. So three real full stops trip its empty-label check, and
an ellipsis walks past that check and comes out as the same three
bytes. Whatever validates a name has to run after the last step that
can turn something into a dot.
5. A DOT THAT BELONGS TO A LETTER
------------------------------------------------------------------------
'İ' U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE
'İ'.lower() 'i̇' 2 code points: U+0069 U+0307
the second one COMBINING DOT ABOVE
its UTF-8 69 cc 87
.split('.') 1 piece
NFKC same
'İ'.lower() == 'i' False
Lowercasing the Turkish capital without Turkish rules keeps its dot as
a combining mark on an ordinary i. Nothing on this page reads that
mark as punctuation, and nothing removes it either.
Section 2 is the mechanism. A decomposition in angle brackets — <compat>, <wide>, <narrow> — is a compatibility mapping, and NFKC applies it: the one dot leader and the fullwidth full stop fold to ., and the ellipsis folds to three of them. The ideographic full stop has no mapping at all, because it is a different punctuation mark that also ends sentences, and its halfwidth form folds onto it, not onto .. So running NFKC before you parse repairs an autocorrected ellipsis in a version string and does nothing for a Japanese full stop in the same place. The one row NFC touches is the Greek ano teleia, whose mapping has no brackets and is therefore canonical: even the form usually called safe replaces it, with a middle dot that is still not a dot.
Sections 3 and 4 are the reader nobody chose. The codec implements IDNA 2003, and RFC 3490 §3.1 ↗ gives it a list: U+002E, U+3002, U+FF0E and U+FF61 must all be recognised as the dot between labels. Everything else reaches a 2e through nameprep, whose NFKC comes from the Unicode 3.2 table Preparing a string is about. The order is the finding. Lib/encodings/idna.py (read on 3.14.7) splits the name on its four dots first and runs nameprep inside each label afterwards, so its empty-label check only ever sees dots that were there before folding: 'a...b' is refused, and 'a…b' comes out as the same three bytes. The same order problem turns '127。0.0.1', which ipaddress refuses, into b'127.0.0.1', which it accepts. A check has to run on the string that will actually be used, which is the whole argument of The check that ran too early. UTS #46 ↗, the processing browsers' URL parsers use instead, maps U+3002, U+FF0E and U+FF61 to U+002E as well, but it maps the whole name before it breaks it into labels, and its mapping table marks U+2024–U+2026 disallowed, a range that holds the one dot leader and the ellipsis — the two look-alikes Python's codec quietly turned into dots (IdnaMappingTable.txt, read 2026-09-14).
Section 5 is the dot that belongs to a letter: without Turkish rules, İ lowercases to i plus U+0307 COMBINING DOT ABOVE, and nothing on this page removes that mark. Case is not a per-character operation has the rest of the Turkish i.
In the terminal¶
A dump's text column is the tool reading bytes as ASCII, not the file, so the dot is where it lies the most.
Verified output of the_full_stop_and_its_lookalikes_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. THE TEXT COLUMN DRAWS A DOT FOR EVERY BYTE IT HAS NO PICTURE FOR
$ printf 'hi\n\t.' | xxd
00000000: 6869 0a09 2e hi...
Three dots on the right and one 2e on the left. The first two are
0a (newline) and 09 (tab): the dump's placeholder, not a full stop.
2. THREE DIFFERENT STRINGS, ONE PICTURE
$ printf '...' | xxd
00000000: 2e2e 2e ...
$ printf '\342\200\246' | xxd
00000000: e280 a6 ...
$ printf '\342\200\244' | xxd
00000000: e280 a4 ...
Three full stops, one HORIZONTAL ELLIPSIS, one ONE DOT LEADER. The
text column says '...' three times; only the first dump holds a 2e,
and the other two hold no byte below 80 at all.
3. GREP IN THE C LOCALE: A DOT IS ONE BYTE, A LOOK-ALIKE IS THREE
$ words | xxd
00000000: 612e 620a 61e2 80a4 620a 61e2 80a6 620a a.b.a...b.a...b.
$ words | grep -c 'a\.b'
1
$ words | grep -c 'a.b'
1
$ words | grep -c 'a...b'
2
$ words | grep 'a...b' | xxd
00000000: 61e2 80a4 620a 61e2 80a6 620a a...b.a...b.
An escaped dot finds the one line holding byte 2e. An unescaped dot is
any ONE byte here, so it cannot span a three-byte look-alike -- while
'a...b', three one-byte dots, matches both look-alikes and not a.b.
Section 1 is the placeholder Reading a hex dump introduces: xxd and hexdump -C print . for every byte outside 20–7e, so a dot in the text column is a question, and the hex column above it is the answer. Section 2 is the version of that question this page is about: three different byte strings, one of which is three full stops, draw the same ....
Section 3 runs under LC_ALL=C, where grep's unescaped . matches one byte. So a.b cannot match either look-alike, and a...b matches both. Under a UTF-8 locale the unescaped counts turn round, because grep then matches one character (grep on text that is not ASCII has why):
| pattern | LC_ALL=C (the key above) |
UTF-8 locale |
|---|---|---|
a\.b |
1 | 1 |
a.b |
1 | 3 |
a...b |
2 | 0 |
The UTF-8 column was measured on 2026-09-14 over the same three lines, with BSD grep 2.6.0-FreeBSD under en_US.UTF-8 on macOS and GNU grep 3.11 under C.UTF-8 on Ubuntu, and the two agreed. It is not in the key because a locale's name and availability differ between the two runners. The escaped dot is the only one of the three patterns whose answer does not depend on the locale at all.
In Rust¶
The Python program showed that none of nine look-alikes contains a 2e. This one checks every character there is.
Verified output of the_full_stop_and_its_lookalikes_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. A u8 AND A char, AND SPLITTING ON EACH
------------------------------------------------------------------------
b'.' 0x2e (a u8)
'.' as u32 U+002E (a char)
char len_utf8 UTF-8 is_ascii_punctuation "a?b".split('.')
U+002E 1 2e true 2 pieces
U+2024 3 e2 80 a4 false 1 piece
U+2026 3 e2 80 a6 false 1 piece
U+3002 3 e3 80 82 false 1 piece
U+FF0E 3 ef bc 8e false 1 piece
All five in one string, split two ways:
str::split('.') 2 pieces
as_bytes().split(|&b| b == b'.') 2 pieces
The same answer from the char and from the byte. Section 2 is why
that is not luck for these five, but true of every string.
2. EVERY SCALAR VALUE, ENCODED, AND SEARCHED FOR 0x2E
------------------------------------------------------------------------
scalar values encoded 1112064
of those, encoded in more than one byte 1111936
...with any byte below 0x80 among them 0
scalar values whose UTF-8 holds a 0x2E 1 (U+002E)
Every byte of a multi-byte UTF-8 sequence is 0x80 or above, so the
byte 0x2E appears in exactly one character's encoding: the full stop
itself. A byte scan for '.' can never cut a look-alike in half --
and, by the same rule, can never find one. Nothing on char says
U+2024 resembles U+002E, and std has no normalization to say it
either.
Of 1,112,064 scalar values, exactly one has a 0x2E anywhere in its UTF-8, and it is the full stop. That is why as_bytes().split(|&b| b == b'.') and str::split('.') always cut a valid string in the same places, and why neither can be taught about look-alikes without a table. std carries no such table: char::is_ascii_punctuation answers true for U+002E and false for all four look-alikes, and normalization lives in a crate (Where std stops measures unicode-normalization). A Rust program that should accept . in a version string has to say so itself.
If you are coming from Python or ABAP¶
Python. str.split('.'), re's \., float() and ipaddress all take U+002E and nothing else, and NFKC is the standard library's only folding, which covers three of the nine. The reader to watch is the one you do not call by name. socket encodes a str host through the idna codec, so on 3.14.7 — macOS and python:3.14-slim, with AI_NUMERICHOST and no network — socket.getaddrinfo('127․0.0.1', 80, AF_INET, SOCK_STREAM, 0, AI_NUMERICHOST) with a one dot leader returns 127.0.0.1, while ipaddress.ip_address raises ValueError on the same string. If you need UTS #46 rather than IDNA 2003, the third-party idna ↗ package implements it.
ABAP. (Not machine-checked — CI cannot run ABAP.) SPLIT … AT '.' and FIND '.' compare characters, so an ellipsis or a fullwidth stop is not a separator to either, and ABAP's own statements end in U+002E, so a fullwidth . pasted where a statement's period belongs does not end the statement. At an interface the question becomes which look-alike it is: Windows-1252 has a byte for … (85) and for · (B7) and none for ․, 。 or . (measured with Python's cp1252 codec), so a conversion to a single-byte code page keeps some of them and substitutes the others. Verify which SAP code page an interface really uses against the system before relying on either behaviour.
Try it¶
- Search a file you copy commands out of — a wiki export, meeting notes — for the three-byte ellipsis:
LC_ALL=C grep -c "$(printf '\342\200\246')" notes.txt. Every hit is a place where three dots stopped being three dots. - Paste a hostname, an IP address or a version number out of a chat message into
python3 -c "print([hex(ord(c)) for c in input() if not c.isalnum()])"and check that every separator is0x2e. - Give your own validator — a hostname check, a version parser, an allow-list —
127。0.0.1and1.2.3. Then find out whether anything before it runs NFKC or theidnacodec. If something does, the validator is checking a different string from the one that gets used. - Dump a file with
xxd, pick every.in the text column that matters, and read the hex above it. Only2eis a full stop. - Run
uni identifyon a dot you are unsure of; the name settles it.
See also¶
- Confusables and scripts — the same rule for letters, where no normalization form merges the look-alikes
- Normalization — the four forms, and what compatibility equivalence means
- "Supports Unicode" is a level, not a yes — what an unescaped regex
.matches, engine by engine - Reading a hex dump — the text column and its placeholder dot, from the beginning
xxdis the dump you can put back — the text column is the onexxd -rignoreshexdumpis a format engine wearing six presets —-C, the dump whose text column is framed in|…|grepon text that is not ASCII — why the locale decides what.matches- The check that ran too early — the order problem section 4 runs into, in general
- Case is not a per-character operation — where the dot of
İgoes - What a dot means to the shell ↗ — the shell's dots: the
.and..entries, hidden files, the.builtin and globs - RFC 3490 §3.1 ↗ — the four characters a hostname must read as dots
- UTS #46, Unicode IDNA Compatibility Processing ↗