Skip to content

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+2024U+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 207e, 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

  1. 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.
  2. 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 is 0x2e.
  3. Give your own validator — a hostname check, a version parser, an allow-list — 127。0.0.1 and 1.2.3. Then find out whether anything before it runs NFKC or the idna codec. If something does, the validator is checking a different string from the one that gets used.
  4. Dump a file with xxd, pick every . in the text column that matters, and read the hex above it. Only 2e is a full stop.
  5. Run uni identify on a dot you are unsure of; the name settles it.

See also