Skip to content

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 LLu, 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_alphabeticstr 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.

Measured 2026-09-06 — not verbatim program output; one run per language, put side by side
                            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+001CU+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

  1. 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?
  2. int() accepts exactly isdecimal(). Verify it: loop over the whole code space, and print every code point where c.isdigit() is True but int(c) raises. Then do the same for isnumeric(). Which of the two is a bigger set, and by how much?
  3. "Dž".isupper() is False and "Dž".upper() is "DŽ". Find the other two members of that family (hint: U+01C4U+01CC holds three of them), and work out what .title() does to a string that already starts with one.

See also