Skip to main content

Validate phone numbers in Python: five traps a regex and a CSV hide

Why a regex is wrong on every number we gave it, what the default region does, possible vs valid, and the blank cell that breaks a whole column. Measured with phonenumbers 9.0

Updated 9 min readMarkdown
Validate phone numbers in Python: five traps a regex and a CSV hide

Disclosure: we built and sell the Apify Actor this post mentions at the end, Phone Number Validator (publisher WrenTools). The post was written with AI assistance and reviewed by a person before publishing. Every output below comes from one script, and every phone number in it is from a range reserved for films and TV, or a broken copy of one.

In short: a typical phone regex (^\+?\d{10,15}$) was wrong on all 10 numbers we tried: it rejected five real formats and accepted five broken numbers. Google's libphonenumber (pip install phonenumbers) gets them right, but only if you give it the right default region, check is_valid_number before you format anything (formatting never refuses), and remember that "valid" means "in an assignable range", not "someone answers". And before any of that, one blank cell in a CSV turned a column of good numbers into floats, and every one came out invalid. The fix: dtype={"phone": str}.

All outputs are from phonenumbers 9.0.41, pandas 3.1.0 and Python 3.14.

1. Why not just use a regex?

Here is the regex most of us have written at some point, against ten numbers whose answer we know:

import re
import phonenumbers

REGEX = re.compile(r"^\+?\d{10,15}$")
for raw, region in trials:  # the (number, default region) pairs below
    n = phonenumbers.parse(raw, region)
    print(raw, bool(REGEX.match(raw)), phonenumbers.is_valid_number(n))
'+44 20 7946 0958'     regex=bad library=valid   truth: valid
'(415) 555-0132'       regex=bad library=valid   truth: valid
'+1 202 555 0143'      regex=bad library=valid   truth: valid
'020 7946 0958'        regex=bad library=valid   truth: valid
'+61 491 570 156'      regex=bad library=valid   truth: valid
'+4420794609581'       regex=ok  library=invalid truth: one digit too many
'+15555550100'         regex=ok  library=invalid truth: no such area code (555)
'+19995550132'         regex=ok  library=invalid truth: no such area code (999)
'2025550143'           regex=ok  library=invalid truth: a US number in a UK list, no +1
'+447700900123'        regex=ok  library=invalid truth: Ofcom's drama mobile block
regex wrong on 10 of 10

We picked these ten to show the problem, so read "10 of 10" as "it fails both ways", not as a rate. Stripping spaces and brackets first fixes the top half. Nothing a regex can do fixes the bottom half, because the answer depends on each country's numbering plan: which lengths it uses, which area codes exist, which blocks are assigned. That plan is the data libphonenumber ships.

The library checks a number in steps, and the rest of this post is about where each step goes wrong. Here are six of the numbers below, each stopping at the first step it fails:

A ladder of four checks, parses to E.164, possible length, valid range, and assigned, with six example numbers. The blank cell fails to parse; the London number read with region US parses to +102079460958 but is not a possible length; the Ofcom drama mobile and the 999 area code are possible but not valid; the London number with region GB and (415) 555-0132 are valid, and whether anyone answers can't be known offline.

2. What does the default region do?

A number written without + means nothing until you say which country it was written in. parse takes that as its second argument, and it changes the answer completely:

'020 7946 0958'          US -> ('+102079460958', False, False)   GB -> ('+442079460958', True, True)
'00 44 20 7946 0958'     US -> ('+100442079460958', False, False)   GB -> ('+442079460958', True, True)
'011 44 20 7946 0958'    US -> ('+442079460958', True, True)   GB -> ('+4411442079460958', False, False)
'(415) 555-0132'         US -> ('+14155550132', True, True)   GB -> ('+444155550132', True, False)

Each tuple is (E.164, possible, valid). A London number read as American becomes +102079460958. The international prefix depends on the region too: 00 works from the UK, 011 from the US, and each one is nonsense from the other. Passing None raises Missing or invalid default region, which is the honest answer.

So use the region where the number was collected: the country field of the same form, the shop's country, the CRM's country column. One default for a whole mixed list is how UK customers end up with +1 numbers.

3. Possible is not valid, and formatting never refuses

libphonenumber has two checks. is_possible_number asks only "is the length right for this country?". is_valid_number asks "is it in a range the plan assigns?". And format_number will format anything you hand it:

'07700 900123'       region=GB E164='+447700900123'   possible=True  valid=False reason=0
'+44 20 7946 095'    region=GB E164='+44207946095'    possible=True  valid=False reason=0
'+1 999 555 0132'    region=US E164='+19995550132'    possible=True  valid=False reason=0
'020 7946 0958'      region=US E164='+102079460958'   possible=False valid=False reason=3
'555-0132'           region=US E164='+15550132'       possible=True  valid=False reason=4

Every row produced a tidy E.164 string, and not one of them is a number you can store. Two details:

  • 07700 900123 is the right length but sits in Ofcom's block for drama, which the library does not treat as assignable. Same for the London number with a digit missing: still a "possible" length, but no such range.
  • reason=4 is IS_POSSIBLE_LOCAL_ONLY: a seven-digit US number with no area code. It might ring from a phone in the right town, but nobody can turn it into a full number.

The rule: call is_valid_number first, and write the E.164 form only when it says True. Keep the original text beside it either way.

One more thing E.164 throws away: extensions. +44 20 7946 0958 ext. 123 parses with extension='123' on the object, but its E.164 form is +442079460958, the switchboard. If extensions matter to you, store n.extension in its own column.

4. Does "valid" mean the number works?

No. Here are two US numbers from the 555-0100 to 555-0199 block, which is reserved for fictional, non-working numbers:

'(415) 555-0132'     valid=True  type=FIXED_LINE_OR_MOBILE
'+1 202 555 0143'    valid=True  type=FIXED_LINE_OR_MOBILE
'020 7946 0958'      valid=True  type=FIXED_LINE
'+61 491 570 156'    valid=True  type=MOBILE

All four are "valid", and none of them will ever ring. libphonenumber's FAQ says so plainly: a valid range is one "from which numbers can be freely assigned", and you should not rely on it "to determine whether numbers are currently assigned to a specific user and reachable". The only way to know that is to call or text the number with a code, which is what sign-up verification does.

The type has the same limit. Some plans mark a range as "fixed line or mobile", so the library can only say FIXED_LINE_OR_MOBILE; both US numbers above got that. If you were going to keep only the mobiles for an SMS campaign, a US list won't split that way.

5. The spreadsheet breaks the number before the library sees it

This one surprised us. The folklore says read_csv drops leading zeros, and it does, when the column is all digits:

digits only: dtype int64 [7700900123, 447700900123, 2079460958]
  7700900123     as read, GB -> ('+447700900123', True, False)
  447700900123   as read, GB -> ('+447700900123', True, False)
  2079460958     as read, GB -> ('+442079460958', True, True)

The library recovers from that: with the region set, 2079460958 still becomes the right London number. (The Ofcom number is still invalid, as in trap 3.)

Predict: now add one row with no phone at all. You read the file the same way, call str() on each value and parse it with region GB. Does 2079460958 still come out as the valid London number?

What it can't recover from is the blank cell. Add one row with no phone, and pandas makes the whole column float64 so it can hold NaN:

digits only, one blank cell: dtype float64 [2079460958.0, nan, 2025550143.0]
  str(v)='2079460958.0'   GB -> ('+4420794609580', False, False)
  str(v)='nan'            GB -> NumberParseException: (1) The string supplied did not seem to be a phone number.
  str(v)='2025550143.0'   US -> ('+120255501430', False, False)

str() of a float adds .0, the parser treats the 0 as one more digit, and two good numbers come out invalid. One empty cell anywhere in the file is enough. The fix is to never let pandas guess the type of a phone column:

df = pd.read_csv("contacts.csv", dtype={"phone": str}, keep_default_na=False)
dtype={'phone': str}, keep_default_na=False: ['02079460958', '', '2025550143']
  '02079460958'  GB -> ('+442079460958', True, True)
  ''             GB -> NumberParseException: (1) The string supplied did not seem to be a phone number.
  '2025550143'   US -> ('+12025550143', True, True)

keep_default_na=False keeps the blank as '' instead of NaN, so it can't be turned into the string 'nan' by accident. Then catch NumberParseException and record it as "empty" rather than letting it stop the loop. If the file went through Excel first, the damage may already be done: open it with the phone column set to Text, or export it again from the source.

Questions people ask

Is phonenumbers the same as Google's libphonenumber? It's the Python port of it, and it ships the same numbering-plan data. That data changes as countries open new ranges, so pin a version and update it now and then; an old pin can call a newly opened range invalid.

Can I tell if a number is on WhatsApp, or who owns it? Not from the number itself. That takes a live lookup with a carrier or the app, and libphonenumber works entirely offline.

What should I store? The original text, the E.164 form when it's valid, the region you parsed it with, and the extension if there is one. E.164 (+442079460958) is the form to deduplicate on: it's one string per number however it was typed, which is what post 7 on deduplicating lead lists needed for its phone column.

Try it

If you'd rather not maintain this, our Phone Number Validator Actor runs the checks above on up to 200,000 numbers per run. It reports isPossible and isValid separately, gives the E.164, international and national forms, the region, the type and the time zones only when a number is valid, and names the reason when it isn't (too-short, invalid-country-code, not-in-numbering-plan and so on). It uses libphonenumber's data and makes no network lookups, so your numbers never leave the run. Two honest limits: it takes one defaultRegion per run, so split a mixed list by country (or give the numbers a + prefix) first, and it does not return extensions. It costs $0.0004 per number ($0.40 per 1,000).

Which one has bitten you: the regex, the wrong region, or the blank cell? Tell us in the comments.

No comments yet. Be the first to comment.