Is it a letter?¶
Level: 201 · for Python programmers
One line: str.isalpha() does not mean "every character is a letter" — it means "every character is in General_Category L, and there is at least one" — and the four that sound like near-synonyms — isalnum, isdecimal, isdigit, isnumeric — each read a different column of the Unicode table.
You reach for these when validating input: a username, a postcode, a column in a CSV somebody else produced. They are the friendliest-looking methods in str — one word, no arguments, returns a bool — and that is exactly why they get used for rules they do not implement. isalnum() is not "letters and digits", isdigit() is not "you can call int() on it", and none of them is all().
Verified output of is_it_a_letter_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. TWELVE PREDICATES, AND WHAT THE EMPTY STRING DOES
str has 12 methods whose name starts with 'is'.
False on '': isalnum isalpha isdecimal isdigit isidentifier
islower isnumeric isspace istitle isupper
True on '': isascii isprintable
The 10 say 'every character is X, and there is at least one'.
The 2 say 'no character is NOT X', which is vacuously true when
there are no characters. Same suffix, opposite empty-string rule.
2. WHAT isalpha ACTUALLY ASKS
code point cat alpha numeric digit name
----------------------------------------------------------------------------
U+0041 Lu True False False LATIN CAPITAL LETTER A
U+0141 Lu True False False LATIN CAPITAL LETTER L WITH STROKE
U+01C5 Lt True False False LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON
U+0345 Mn False False False COMBINING GREEK YPOGEGRAMMENI
U+093E Mc False False False DEVANAGARI VOWEL SIGN AA
U+16EE Nl False True False RUNIC ARLAUG SYMBOL
U+2167 Nl False True False ROMAN NUMERAL EIGHT
U+00B2 No False True True SUPERSCRIPT TWO
U+00BD No False True False VULGAR FRACTION ONE HALF
U+0663 Nd False True True ARABIC-INDIC DIGIT THREE
U+4E00 Lo True True False CJK UNIFIED IDEOGRAPH-4E00
U+005F Pc False False False LOW LINE
isalpha() is exactly 'General_Category starts with L'. That is why
the two marks are False -- they are Mn and Mc, and a mark is not a
letter however alphabetic it looks -- and why U+4E00 is True: a CJK
ideograph is Lo, so it is a letter AND it has a numeric value.
3. THREE KINDS OF NUMBER, NESTED
code point isdecimal isdigit isnumeric name
--------------------------------------------------------------------
U+0033 True True True DIGIT THREE
U+0663 True True True ARABIC-INDIC DIGIT THREE
U+00B2 False True True SUPERSCRIPT TWO
U+00BD False False True VULGAR FRACTION ONE HALF
U+2167 False False True ROMAN NUMERAL EIGHT
U+4E00 False False True CJK UNIFIED IDEOGRAPH-4E00
Checked over every one of the 1,114,112 code points:
isdecimal implies isdigit implies isnumeric, violations = 0
isdecimal is 'you can build a base-10 number out of it' (Nd).
isdigit adds the ones with a digit VALUE but no positional use.
isnumeric adds everything with a numeric value at all -- halves,
Roman numerals, and the CJK ideograph for 'one'.
4. isalnum IS THE UNION OF FOUR, NOT OF TWO
U+0041 isalnum=True alpha or decimal or digit or numeric = True
U+00BD isalnum=True alpha or decimal or digit or numeric = True
U+005F isalnum=False alpha or decimal or digit or numeric = False
So U+00BD (one half) is 'alphanumeric' in Python. If you are
validating a username, isalnum() is almost never the rule you meant.
5. isupper IGNORES EVERY UNCASED CHARACTER
'ABC' isupper=True islower=False istitle=False
'ABC1' isupper=True islower=False istitle=False
'123' isupper=False islower=False istitle=False
'ABC-DEF' isupper=True islower=False istitle=False
'Ł' isupper=True islower=False istitle=True
'Dž' isupper=False islower=False istitle=True
'ABC1' is uppercase because the digit has no case to disagree with.
'123' is not, because there is no cased character to agree with it.
The rule is 'at least one cased character, and no cased character
is in the wrong case' -- not 'every character is uppercase'.
6. NONE OF THESE IS all()
'' .isalpha() = False all(c.isalpha()) = True
'abc' .isalpha() = True all(c.isalpha()) = True
'ab1' .isalpha() = False all(c.isalpha()) = False
They agree on every string except the empty one, where all() is
True by definition and isalpha() is False by decision. Translating
s.isalpha() into a language that only has per-character predicates
means writing the emptiness test back in by hand.
7. bytes HAS EIGHT OF THE TWELVE, AND THEY ARE ASCII-ONLY
bytes has 8: isalnum isalpha isascii isdigit islower isspace istitle isupper
str-only: isdecimal isidentifier isnumeric isprintable
text codec the bytes bytes str
--------------------------------------------------------------
'Lodz' utf-8 b'Lodz' True True
'Łódź' utf-8 b'\xc5\x81\xc3\xb3d\xc5\xba' False True
'Łódź' cp1250 b'\xa3\xf3d\x9f' False True
The last column is str.isalpha() on the text those bytes decode to.
The four missing ones are the four that need the Unicode table:
isdecimal, isnumeric, isprintable, isidentifier. The eight that
remain answer only for ASCII, because a bytes object does not know
which table produced it -- so a Polish name is 'not alphabetic' in
every encoding, and the same call on the str it decodes to is True.
The empty string is the fault line, and it splits the family in two. Ten of the twelve return False on ""; isascii() and isprintable() return True. That is not an inconsistency — the ten are "every character is X, and there is at least one", while the two are "no character is not X", which is vacuously true of nothing. It matters because the ten are the ones people use for validation, and "" failing them is the behaviour you wanted; it is also the single place the obvious translation into another language goes wrong.
isalpha() is a General_Category test and nothing more. It is true when every character's category starts with L — Lu, Ll, Lt, Lm, Lo. Both marks in the table are False for that reason alone: U+0345 and the Devanagari vowel sign are Mn and Mc, and a combining mark is not a letter however alphabetic it looks in a word. So "क" + "ा" — one syllable a Hindi reader would call a character — is not isalpha(), because the second half is a mark. If your rule is "this is a word in some language", isalpha() is a decent approximation right up to the first script that writes its vowels as marks.
Three kinds of number, nested, and the one you want is almost always isdecimal(). isdecimal is Nd: a positional base-10 digit you can string together into a number, which is what int() accepts. isdigit adds characters with a digit value but no positional use — superscript two. isnumeric adds everything with a numeric value at all: one half, Ⅷ, and 一. The nesting holds over the entire code space (the program checks all 1,114,112 of them), so you never need more than one of the three — pick the widest one your rule can survive. int() accepts isdecimal and nothing wider: int("٣") gives 3 because Arabic-Indic three is Nd, and int("²") raises ValueError even though "²".isdigit() is True. isdigit() is the one that reads like the int() guard and is not it.
isalnum() is the union of four predicates, not two. A character passes if it is isalpha or isdecimal or isdigit or isnumeric — which is why ½ is alphanumeric in Python. For a username rule, s.isalnum() accepts Roman numerals, vulgar fractions and Arabic-Indic digits. That is usually not what the ticket said. Write the set you mean.
isupper() is not "every character is uppercase". It is "at least one cased character, and no cased character is lowercase or titlecase". Uncased characters — digits, punctuation, spaces — are ignored entirely, so "ABC-DEF" and "ABC1" are both uppercase and "123" is not. The one that surprises people is Dž (U+01C5): it is istitle() and neither isupper() nor islower(), because Unicode has a third case and Python reports it honestly.
bytes has eight of the twelve, and they answer only for ASCII. The four it does not have are the four that need the Unicode table — isdecimal, isnumeric, isprintable, isidentifier — and the eight that remain are deliberately ASCII-only, because a bytes object does not know which table produced it. So "Łódź".encode().isalpha() is False, in UTF-8 and in cp1250 alike, while the str those bytes decode to is True. That is the whole type boundary restated as a predicate: the same question, asked of the thing that has an encoding and of the thing that has characters, gives different answers on purpose.
The Rust view¶
Rust has the same ideas and does not have this family. There is no str::is_alphabetic — str carries exactly three is* methods (is_empty, is_ascii, is_char_boundary), and everything else is a predicate on char that you apply yourself. The translation of s.isalpha() is therefore not s.chars().all(char::is_alphabetic); it is that plus the emptiness test you did not know you were relying on, and even then the two disagree, because they read different columns of the same table.
Python 3.14.7 rustc 1.98.0
input s.isalpha() s.chars().all(char::is_alphabetic)
------------------------------------------------------------------------------
"" False true <- vacuous all()
"abc" True true
"\u0345" (Mn) False true <- Other_Alphabetic
"\u093E" (Mc) False true <- Other_Alphabetic
"\u16EE" (Nl) False true <- Nl is Alphabetic
"\u2167" (Nl) False true
input s.isnumeric() s.chars().all(char::is_numeric)
------------------------------------------------------------------------------
"\u0663" (Nd) True true
"\u4E00" (Lo) True false <- the other direction
input s.isupper() s.chars().all(char::is_uppercase)
------------------------------------------------------------------------------
"ABC1" True false <- Python ignores uncased
is_alphabetic is the Alphabetic property; isalpha is the L* categories. Alphabetic is defined as L* plus Nl plus Other_Alphabetic, and that last piece is what pulls in the two marks: Rust says the Devanagari vowel sign is alphabetic, Python says it is a mark. Neither is a bug. Rust's answer is the better one for "does this belong to a word", Python's is the better one for "is this a letter", and the two questions are not the same question.
The 一 row is the one that keeps this honest, because it goes the other way. Python's isnumeric() follows the Numeric_Type property, which a CJK ideograph has; Rust's is_numeric is the N* categories, which Lo is not. So Rust is broader on letters and Python is broader on numbers, from methods that look like translations of each other.
And char::is_digit is a false friend. It takes a radix and is to_digit(radix).is_some() — ASCII only — so '٣'.is_digit(10) is false where "٣".isdigit() is True. The Rust method that answers Python's question is is_numeric; the Rust method with Python's name answers a different one.
The sibling library owns the char side of this: Meet the char ↗ is where the predicates live, and RFC 1054: str::words ↗ works the same comparison through whitespace — str.isspace() counts U+001C–U+001F and Rust's White_Space property does not, so "a\x1cb" is two pieces in Python and one in Rust. And because every row above is a table lookup rather than a rule, the table has a version ↗ applies to all of it: is_alphabetic and isalpha also disagree whenever the two toolchains were built against different editions of the UCD.
If you are coming from ABAP¶
There is no predicate family at all. The idiom is a character set and a CO (contains only) comparison — IF lv_text CO '0123456789' — which is isdecimal() restricted to ASCII and written out longhand. It is worth checking on your own system what CO returns for an initial field before relying on it as a validation: "contains only" is the phrasing that produces the vacuous-all() trap in every language that has it, and Python's ten predicates are unusual in having been designed the other way. For anything wider you reach for cl_abap_matcher / FIND REGEX and inherit whatever that engine means by \d, undocumented at the call site. The practical consequence for a Python job reading SAP data: a field that passed CO '0123456789' upstream is guaranteed isdecimal() here, but a field that passed a regex \d+ is not. (Not machine-checked — CI cannot run ABAP.)
Try it¶
- Write the username rule you actually meant. Start from
s.isalnum(), then find the three inputs from the table above that it lets through and your rule should not. How short is the correct version? int()accepts exactlyisdecimal(). Verify it: loop over the whole code space, and print every code point wherec.isdigit()isTruebutint(c)raises. Then do the same forisnumeric(). Which of the two is a bigger set, and by how much?"Dž".isupper()isFalseand"Dž".upper()is"DŽ". Find the other two members of that family (hint:U+01C4–U+01CCholds three of them), and work out what.title()does to a string that already starts with one.
See also¶
- Lowercasing is not folding — the conversion half of this page:
islower/isupper/istitleask the question,lower/upper/title/casefoldchange the answer, andDžturns up in both - Counting characters — the same table, asked about length instead of class
- Sorting is not comparing — the other place a property lookup stands in for a human rule
stris notbytes— why the eightbytespredicates can only answer for ASCII- The crosswalk — which idea lives in which library
- "Supports Unicode" is a level, not a yes ↗ — the same General_Category columns read by a regex engine instead of a method:
\dis exactlyNdand\wis not what the specification says it is - Python text in practice ↗ — the same ASCII-only rule running through
re, where\wmeans one thing againststrand another againstbytes str.isalpha()in the Python docs ↗ — the definition this page is unpacking