To validate phone numbers in Python, use the phonenumbers library (the Python port of Google’s libphonenumber) for offline checks and formatting, and call a phone validation API when you need the carrier and line type for any country, the US included. This guide covers both, plus batch validation for lists and a Django form field. We ran every snippet with Python 3.15 and phonenumbers 9.0.42; the API calls ran against a local stub of the numlookupapi API that returns the documented responses.
Offline validation with phonenumbers
pip install phonenumbers
# phone_check.py – pip install phonenumbers
import phonenumbers
from phonenumbers import PhoneNumberFormat, PhoneNumberType, carrier, geocoder
TYPES = {
PhoneNumberType.MOBILE: "mobile",
PhoneNumberType.FIXED_LINE: "landline",
PhoneNumberType.FIXED_LINE_OR_MOBILE: "landline_or_mobile", # US and Canada
PhoneNumberType.VOIP: "voip",
PhoneNumberType.TOLL_FREE: "toll_free",
PhoneNumberType.PREMIUM_RATE: "premium_rate",
}
def check(number: str, default_region: str = "US") -> dict:
try:
parsed = phonenumbers.parse(number, default_region)
except phonenumbers.NumberParseException:
return {"valid": False} # not a phone number at all
return {
"valid": phonenumbers.is_valid_number(parsed),
"e164": phonenumbers.format_number(parsed, PhoneNumberFormat.E164),
"international": phonenumbers.format_number(parsed, PhoneNumberFormat.INTERNATIONAL),
"type": TYPES.get(phonenumbers.number_type(parsed), "other"),
"region": phonenumbers.region_code_for_number(parsed),
"location": geocoder.description_for_number(parsed, "en"),
"original_carrier": carrier.name_for_number(parsed, "en"),
}
What it returned:
>>> check("0803 123 4567", "NG")
{'valid': True, 'e164': '+2348031234567', 'international': '+234 803 123 4567',
'type': 'mobile', 'region': 'NG', 'location': 'Nigeria', 'original_carrier': 'MTN'}
>>> check("+1 415 858 6273")
{'valid': True, 'e164': '+14158586273', 'international': '+1 415-858-6273',
'type': 'landline_or_mobile', 'region': 'US', 'location': 'California', 'original_carrier': ''}
>>> check("+1 555 555 5555")
{'valid': False, 'e164': '+15555555555', 'international': '+1 555-555-5555',
'type': 'other', 'region': None, 'location': '', 'original_carrier': ''}
>>> check("call me maybe")
{'valid': False}
What the functions do:
phonenumbers.parse(number, region)reads the input. The region is the default country for numbers without+; numbers with+or00ignore it. Without a region and without+, it raisesNumberParseException(“Missing or invalid default region”).is_valid_number()checks length and prefix against the numbering plan;is_possible_number()checks only the length.+1 555 555 5555is possible but not valid.number_type()returnsFIXED_LINE_OR_MOBILEfor US and Canadian numbers, because both share the same ranges there; amobileflag based onPhoneNumberType.MOBILEalone would call every US number “not mobile”.carrier.name_for_number()returns the network a number block was originally assigned to, and an empty string for US and Canadian numbers, as the output above shows. libphonenumber’s documentation says it does not provide the current carrier.
The library runs without network access and is the right tool for format checks and normalisation. Store the E.164 form.
Carrier and line type with the API
The numlookupapi API returns carrier, line type, location, time zones and every format for any number, including US numbers. Get a free API key on the pricing page (250 requests a month for testing and personal projects, no credit card; commercial use from the Basic plan) and set it as NUMLOOKUPAPI_KEY.
# phone_api.py – Python 3.10 or later, pip install requests
import os
from urllib.parse import quote
import requests
API = "https://api.numlookupapi.com/v1/validate"
session = requests.Session()
session.headers["apikey"] = os.environ["NUMLOOKUPAPI_KEY"]
def lookup(number: str, country: str | None = None) -> dict:
params = {"country_code": country} if country else None
res = session.get(f"{API}/{quote(number)}", params=params, timeout=10)
if res.status_code == 422:
# malformed input: invalid_number (empty) or invalid_country_code
return {"valid": False, "error": res.json().get("message")}
res.raise_for_status() # 401 key, 429 quota or rate limit
return res.json() # an unknown number is 200 with "valid": false
>>> data = lookup("+14158586273")
>>> data["valid"], data["line_type"], data["carrier"], data["e164_format"]
(True, 'mobile', 'AT&T Mobility LLC', '+14158586273')
>>> lookup("4158586273", country="US")["e164_format"]
'+14158586273'
The response has valid, country_code, location, carrier, line_type, number_type (with voip for internet-based numbers and landline_or_mobile for US numbers), timezones and the number in E.164, national, international and RFC 3966 formats. Check valid rather than the status code: a number that is not in use returns HTTP 200 with "valid": false. A 429 means the monthly quota is used up or, on the free plan, more than 10 requests a minute were sent.
Like the library, the API’s carrier names the network the number range was assigned to. For the network after porting and whether the phone is reachable right now, add hlr=1 on a paid plan (see the HLR lookup API).
Validate a list in batches
POST /v1/validate takes up to 100 numbers per request. Each number counts as one request, identical entries in a batch are validated and billed once, and a number that cannot be read gets "error": "invalid_number" in its own entry instead of failing the request:
def lookup_many(numbers: list[str], country: str | None = None) -> dict[str, dict]:
unique = list(dict.fromkeys(numbers)) # keep the order, drop repeats
results: dict[str, dict] = {}
for start in range(0, len(unique), 100): # up to 100 numbers per request
body = {"numbers": unique[start : start + 100]}
if country:
body["country_code"] = country
res = session.post(API, json=body, timeout=30)
res.raise_for_status()
results.update(res.json()["data"]) # keyed by the number as sent
return results
country_code applies to every number without + or 00. The same number written two ways counts twice, so normalise with check() first and send the e164 values. For a CSV of contacts:
import csv
with open("contacts.csv", newline="") as f:
phones = [row["phone"] for row in csv.DictReader(f)]
results = lookup_many(phones, country="US")
invalid = [number for number, row in results.items() if not row["valid"]]
Without code, the bulk phone validator checks a list in your browser, and the batch upload in the numlookupapi dashboard takes CSV, TXT and Excel files.
Django form field
Validate and normalise in the form, so every stored number is E.164:
from django import forms
import phonenumbers
class SignupForm(forms.Form):
phone = forms.CharField(max_length=32)
def clean_phone(self):
raw = self.cleaned_data["phone"]
try:
parsed = phonenumbers.parse(raw, "US") # default region for numbers without +
except phonenumbers.NumberParseException:
raise forms.ValidationError("Enter a valid phone number.")
if not phonenumbers.is_valid_number(parsed):
raise forms.ValidationError("Enter a valid phone number.")
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
With Django 6.1, (415) 858-6273 became +14158586273, +44 20 7946 0958 became +442079460958, and 555-555-5555 and hello got the error. If you need the carrier or VoIP detection at sign-up, call lookup() after the form is valid, ideally in a background task (Celery, RQ or Django’s task framework), and store line_type and number_type next to the number. The django-phonenumber-field package wraps the same library as a model and form field if you prefer a ready-made field.
More languages: phone number validation in JavaScript and in PHP. For the format itself, see E.164 explained.