How to validate a phone number in Python
The phonenumbers library validates and normalizes phone numbers offline for free — here's how, and the honest boundary where an API earns its place.
Contents
You searched "validate phone number in Python" because a form field, a signup flow, or a CSV of leads handed you strings that may or may not be real phone numbers. Good news up front: for most of what you need, Python already has the answer, and it costs nothing. The phonenumbers package — the maintained port of Google's libphonenumber — parses, validates, and normalizes numbers entirely offline. Reach for a network call only when you actually need something local data can't tell you.
Start with phonenumbers, not a network call
phonenumbers is the same rules engine that ships inside Android's dialer, repackaged for Python. Install it and you get every country's numbering plan locally:
pip install phonenumbers
Parsing takes a string and a default region. If the number is already in E.164 form — a leading + and country code — pass None for the region; the + tells the parser everything it needs:
import phonenumbers
number = phonenumbers.parse("+16504472983", None)
If your input is a national format without the +, give parse the region to interpret it against:
number = phonenumbers.parse("(650) 447-2983", "US")
Validate and format to E.164
Two calls do the work most people mean by "validate a phone number in Python." is_valid_number checks the parsed number against the region's actual numbering rules — length, prefix, allocated ranges — not just whether it looks phone-shaped. format_number with PhoneNumberFormat.E164 gives you the one canonical string worth storing:
import phonenumbers
from phonenumbers import PhoneNumberFormat
number = phonenumbers.parse("+16504472983", None)
phonenumbers.is_valid_number(number) # True
phonenumbers.format_number(number, PhoneNumberFormat.E164) # "+16504472983"
Wrap it so a garbage string returns None instead of raising. parse throws NumberParseException on input it can't even tokenize:
import phonenumbers
from phonenumbers import PhoneNumberFormat
def to_e164(raw, region=None):
try:
number = phonenumbers.parse(raw, region)
except phonenumbers.NumberParseException:
return None
if not phonenumbers.is_valid_number(number):
return None
return phonenumbers.format_number(number, PhoneNumberFormat.E164)
Store the E.164 result, not the string the user typed. (For why that one format outranks the others, see what E.164 is and why it wins.) If you also do this in a Node service, the JavaScript walkthrough is validating phone numbers in JavaScript — same library, same rules, so your two stacks agree.
number_type is metadata — and it can be stale
phonenumbers also classifies a number:
from phonenumbers import PhoneNumberType
phonenumbers.number_type(number) # PhoneNumberType.FIXED_LINE_OR_MOBILE
Read that result honestly. For US numbers you will very often get FIXED_LINE_OR_MOBILE — the metadata can't separate the two, so the honest reading is "I don't know which." That's a feature: the library declines to guess.
It also ships two submodules that look like they answer the next question, and this is where people get burned:
from phonenumbers import carrier, geocoder
number = phonenumbers.parse("+16504472983", None)
carrier.name_for_number(number, "en") # the carrier the block was ASSIGNED to
geocoder.description_for_number(number, "en") # "San Francisco, CA" — a label, not a location
Both read from static, bundled metadata. carrier.name_for_number returns the carrier a number range was originally allocated to — which number portability makes wrong the moment a subscriber switches networks. geocoder returns the region a number was issued in, not where the handset is. Neither is a live lookup, and for portable US numbers carrier frequently returns an empty string because it simply doesn't know. Feed a "carrier" derived from this into a routing or fraud decision and you're acting on a guess that ages every day.
Where an API fits — and where it doesn't
Here's the honest boundary. Everything above runs offline, for free, and for validating and normalizing a number it is genuinely all you need. You do not need an API to call is_valid_number.
A validation API earns its place at two specific edges. First, one consistent contract across languages: your Python worker, your Node service, and your no-code automation all hit the same endpoint and get the same answer, instead of each pinning its own libphonenumber build. Second — the part offline data cannot do — the real carrier and liveness layer, available on request with hlr:true: the current carrier for a ported number, and HLR liveness that checks whether a line is actually reachable right now. Boundstone doesn't do those today (more on that below).
import requests
resp = requests.post(
"https://api.boundstone.io/v1/verify/phone",
headers={"Authorization": "Bearer bs_live_YOUR_KEY"},
json={"phone": "+16504472983"},
)
data = resp.json()
data["valid"] # True
data["e164"] # "+16504472983"
The honesty contract
Boundstone's answer for a phone number tells you exactly what it did and did not check:
{
"checks": {
"performed": ["format", "region", "line_type_metadata"],
"not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
}
}
Today, carrier_lookup, ported_status, and hlr_liveness sit in not_performed — and that's deliberate. We'd rather report "not checked" than hand you the static carrier guess that phonenumbers.carrier would give, because a stale carrier looks like an answer and isn't. Those live checks are a paid opt-in: send hlr:true (5 credits, paid plans) and they move into performed only when Boundstone actually runs them, refunded when the network cannot answer. Until then, a "valid": true from us means format and region — the same thing the offline library can tell you, plus one contract your whole stack shares.
The short version
- Validating a phone number in Python? Use
phonenumbers:parse,is_valid_number,format_number(..., PhoneNumberFormat.E164). Offline, free, and enough. number_type,carrierandgeocoderare static metadata. Treatcarrier/geocoderas origin labels, never as a live carrier or location.- Reach for the API for one contract across languages — or for the live carrier/HLR layer, which Boundstone runs on request with
hlr:truerather than faking. - Kick the tires without a key at the free phone validator.