On this page
To validate a phone number in Python, parse it with phonenumbers.parse(), check phonenumbers.is_valid_number(), and store the result of format_number(..., PhoneNumberFormat.E164). That tells you the number fits its country's numbering plan. It doesn't tell you whether the line is a mobile or in service. For that, send the E.164 number to a lookup API. This guide has tested code for both, plus a Pydantic validator and pytest tests.
Which Python library should you use?
Use phonenumbers, the Python port of Google's libphonenumber (Apache 2.0 licence). Its version numbers follow Google's: phonenumbers 9.0.40 on PyPI matches libphonenumber 9.0.40, released on 2026-09-23. That matters, because numbering plans change often. Google shipped 25 metadata releases in 2025 and 19 more by late September 2026, each one updating the phone metadata of at least one country.
pip install phonenumbers==9.0.40 httpx pydanticThe full package installs about 46 MB, most of it offline geocoding data (38 MB in our install). If you only parse, validate and format, phonenumberslite offers the same API without the geocoder, carrier and time-zone data.
Avoid regex-only validation. A pattern like ^\+[1-9]\d{1,14}$ accepts numbers no country has allocated. E.164 regex: why a pattern is not enough shows real counterexamples.
How do you parse and validate a number?
This function returns either an E.164 number with its region and type, or a reason. It's the same flow our API uses on every request.
# phone.py
import re
import phonenumbers
from phonenumbers import NumberParseException, PhoneNumberFormat, PhoneNumberType, ValidationResult
# Our reserved test range (+44 7700 9xxxxx). libphonenumber marks it invalid on purpose.
TEST_RANGE = re.compile(r"^\+4477009\d{5}$")
LENGTH_REASONS = {ValidationResult.TOO_SHORT: "too_short", ValidationResult.TOO_LONG: "too_long"}
def normalize_phone(raw: str, region: str | None = None, allow_test_range: bool = False) -> dict:
raw = (raw or "").strip()
if not raw or len(raw) > 32:
return {"ok": False, "reason": "unparseable"}
try:
p = phonenumbers.parse(raw, region) # region like "GB"; None requires a leading +
except NumberParseException:
return {"ok": False, "reason": "unparseable"}
e164 = phonenumbers.format_number(p, PhoneNumberFormat.E164)
if allow_test_range and TEST_RANGE.match(e164):
return {"ok": True, "e164": e164, "region": "GB", "type": "TEST"}
if not phonenumbers.is_valid_number(p):
why = phonenumbers.is_possible_number_with_reason(p)
return {"ok": False, "reason": LENGTH_REASONS.get(why, "invalid")}
return {
"ok": True,
"e164": e164,
"region": phonenumbers.region_code_for_number(p),
"type": PhoneNumberType.to_string(phonenumbers.number_type(p)),
}Real results from Python 3.12 with phonenumbers 9.0.40:
| Call | Result |
|---|---|
normalize_phone("(202) 555-0143", "US") | ok, +12025550143, US, FIXED_LINE_OR_MOBILE |
normalize_phone("07911 123456", "GB") | ok, +447911123456, GG, MOBILE |
normalize_phone("+1 800 555 0199") | ok, +18005550199, US, TOLL_FREE |
normalize_phone("+44 79111 234567") | too_long |
normalize_phone("+1 123 456 7890") | invalid |
normalize_phone("202-555-0143") | unparseable (no region, no +) |
normalize_phone("07700 900001", "GB") | invalid (reserved drama range) |
Note the GG: +44 7911 numbers belong to Guernsey. Take the region from region_code_for_number(), never from a country-code lookup table of your own.
When should you use is_possible_number instead of is_valid_number?
is_possible_number() checks only the length for the country. is_valid_number() also checks the leading digits against allocated ranges. The difference shows up with numbers like +1 123 456 7890: ten digits is the right length for the US, so it's possible, but no US area code starts with 1, so it isn't valid.
Use is_possible_number() for "keep typing" hints in a UI, and is_valid_number() before you store a number or spend money on it. is_possible_number_with_reason() returns a ValidationResult (TOO_SHORT, TOO_LONG, INVALID_COUNTRY_CODE…), which makes friendlier error messages than a plain "invalid".
One more trap: number_type() and the phonenumbers.carrier module describe the range a number was allocated from. After a number is ported, both can be wrong. See why carrier lookups can be wrong.
How do you validate phone numbers in Pydantic?
Pydantic v2 validators can transform the value, so the model stores E.164 no matter what the user typed:
# models.py
from typing import Annotated
from pydantic import AfterValidator, BaseModel
from phone import normalize_phone
def _e164(value: str) -> str:
r = normalize_phone(value, "GB") # your default region, e.g. from the form's country picker
if not r["ok"]:
raise ValueError(f"not a valid phone number ({r['reason']})")
return r["e164"]
E164Phone = Annotated[str, AfterValidator(_e164)]
class SignUp(BaseModel):
name: str
phone: E164PhoneSignUp(name="Ada", phone="07911 123456").phone is "+447911123456", and phone="12345" raises a ValidationError. The type works in FastAPI request models unchanged. In Django, django-phonenumber-field (8.5.0 on 2026-09-25) wraps the same library in a model and form field. If your region varies per request, validate in a model_validator that reads the country field first.
How do you check the line type and whether the number is in service?
A valid number can still be a landline, a VoIP number or out of service. A carrier lookup returns the current line_type and carrier. Here's a small httpx client for the MobileValidate API. It posts numbers in the body, polls while answers are pending and retries only errors the API marks as retryable, respecting Retry-After:
# mvclient.py
import os
import time
import httpx
API = os.environ.get("MOBILEVALIDATE_BASE_URL", "https://api.mobilevalidate.com")
class ApiError(Exception):
def __init__(self, code: str, status: int, message: str = ""):
super().__init__(f"{code} ({status}): {message}")
self.code, self.status = code, status
def _client() -> httpx.Client:
return httpx.Client(
base_url=API,
headers={"Authorization": f"Bearer {os.environ['MOBILEVALIDATE_API_KEY']}"},
timeout=httpx.Timeout(45.0), # the server may long-poll for up to `wait` seconds
)
def _call(client: httpx.Client, method: str, path: str, **kw) -> dict:
for attempt in range(4):
r = client.request(method, path, **kw)
if r.is_success:
return r.json()
try:
err = r.json().get("error", {})
except ValueError: # e.g. an HTML error page from a proxy
err = {"retryable": r.status_code >= 500}
if err.get("retryable") and attempt < 3:
time.sleep(float(r.headers.get("retry-after", 2**attempt)))
continue
raise ApiError(err.get("code", f"http_{r.status_code}"), r.status_code, err.get("message", ""))
def lookup(numbers: list[str], checks: list[str] | None = None) -> list[dict]:
"""Check up to 100 E.164 numbers. Numbers travel in the POST body, never in the URL."""
with _client() as client:
res = _call(client, "POST", "/v1/lookup",
json={"numbers": numbers, "checks": checks or ["carrier"], "wait": 10})
while res["status"] == "pending":
time.sleep((res.get("next") or {}).get("poll_after_ms", 2000) / 1000)
res = _call(client, "GET", f"/v1/lookups/{res['id']}", params={"wait": 10})
out = []
for r in res["results"]:
c = (r.get("checks") or {}).get("network.carrier") or {}
attrs = c.get("attributes") or {}
out.append({
"e164": r.get("e164"),
"number_status": r["number_status"], # valid | invalid_number | duplicate | suppressed
"status": c.get("status"), # completed | unknown | pending | unsupported_country
"line_type": attrs.get("line_type"), # None = unknown, never "not mobile"
"carrier": attrs.get("carrier"),
"reason": c.get("reason"),
"checked_at": c.get("checked_at"),
})
return outWith a free test key (MOBILEVALIDATE_API_KEY=mv_test_…), lookup(["+447700900001", "+447700900004"]) returned this real output. …004 stays pending for about five seconds, so the client polled once:
{'e164': '+447700900001', 'number_status': 'valid', 'status': 'completed', 'line_type': 'mobile',
'carrier': 'Test Carrier', 'reason': None, 'checked_at': '2026-09-25T18:59:29.902Z'}
{'e164': '+447700900004', 'number_status': 'valid', 'status': 'completed', 'line_type': 'mobile',
'carrier': 'Test Carrier', 'reason': None, 'checked_at': '2026-09-25T18:59:34.921Z'}lookup(["+447700900402"]) raised ApiError with code insufficient_balance and status 402, without retrying, because that error isn't retryable. The errors reference lists every code with its retry flag.
How should your code treat each answer?
line_typeismobile: SMS is a sensible channel. Storeline_typewithchecked_at.fixed_line,toll_freeorvoip: don't send SMS. Offer voice or e-mail, or add a verification step if your fraud rules call for it.statusisunknown: keep your default behaviour and retry later. Unknown answers aren't billed.Noneis missing data, so never store it asFalse.number_statusisduplicateorinvalid_number: the API normalizes and deduplicates before checking, and neither is billed.
Don't block a sign-up because a lookup timed out. Fail open on unknown, fail closed only on answers you're sure about.
How do you test it with pytest?
Unit tests need no network. The integration test runs only when a test key is set, and uses the test numbers, which never reach a real network and are never billed:
# test_phone.py
import os
import pytest
from pydantic import ValidationError
from models import SignUp
from phone import normalize_phone
@pytest.mark.parametrize("raw,region,e164", [
("(202) 555-0143", "US", "+12025550143"),
("07911 123456", "GB", "+447911123456"),
("+49 1512 3456789", None, "+4915123456789"),
])
def test_normalizes_to_e164(raw, region, e164):
assert normalize_phone(raw, region)["e164"] == e164
@pytest.mark.parametrize("raw,reason", [
("+1 123 456 7890", "invalid"), # right length, impossible area code
("+44 79111 234567", "too_long"),
("202-555-0143", "unparseable"), # no region, no +
])
def test_rejects(raw, reason):
assert normalize_phone(raw)["reason"] == reason
def test_pydantic_model():
assert SignUp(name="Ada", phone="07911 123456").phone == "+447911123456"
with pytest.raises(ValidationError):
SignUp(name="Ada", phone="12345")
@pytest.mark.skipif(not os.environ.get("MOBILEVALIDATE_API_KEY", "").startswith("mv_test_"),
reason="needs a test key")
def test_lookup_in_test_mode():
from mvclient import lookup
rows = {r["e164"]: r for r in lookup(["+447700900001", "+447700900002", "+447700900003"])}
assert rows["+447700900001"]["line_type"] == "mobile"
assert rows["+447700900002"]["status"] == "unknown" # no data: not billed
assert rows["+447700900003"]["reason"] == "UPSTREAM_TIMEOUT"Our run: 8 passed in 0.24s. The skip guard means CI without a key still runs the offline tests, and a live key can never reach the integration test by accident.
Why does the test range need special handling?
libphonenumber treats +44 7700 900000 to 900999 as invalid. The UK regulator Ofcom reserves the range for TV and radio drama, so it's never assigned to a subscriber. That makes it a safe test range, and it's the one our test mode uses. Your validator would reject those numbers before they reach the API. The allow_test_range flag in normalize_phone() lets them through, and you should set it only when the key starts with mv_test_.
What are the key takeaways?
- Use
phonenumbers(orphonenumberslite) and keep its version current. Its version tracks Google's metadata releases. - Call
is_valid_number()before storing or paying;is_possible_number()is for typing hints. - Store the E.164 string and the region from
region_code_for_number(). - A Pydantic
AfterValidatorgives you E.164 in every model and FastAPI request. - For line type and service status, call a lookup API from your backend with numbers in the POST body. Retry only when the API says
retryable. - Treat
Noneandunknownas missing data, and test every branch for free with test numbers.
Sources
- python-phonenumbers — GitHub (David Drysdale), 2026
- phonenumbers on PyPI — Python Package Index, 2026
- Validators — Pydantic, 2026
- libphonenumber release notes — Google, 2026
Frequently asked questions
What is the best Python library for phone number validation?
phonenumbers, the Python port of Google's libphonenumber. It parses any common format, validates against each country's numbering plan and formats to E.164. If you don't need geocoding or carrier names, phonenumberslite has the same API with a smaller install.
What is the difference between is_possible_number and is_valid_number?
is_possible_number checks only the length for the country. is_valid_number also checks that the digits fall inside an allocated range. +1 123 456 7890 has the right length for the US but is not valid, because no US area code starts with 1.
Why does phonenumbers.parse raise NumberParseException?
Usually because the input has no leading + and you passed no region, so the library can't tell which country the digits belong to. Pass a region such as "GB" or "US", ideally from a country picker on your form.
Does a valid number mean the phone is active?
No. Validation only checks the numbering plan. To know the current line type or whether the number is in service, use a carrier or network lookup from your backend.
Related services and guides
More from the blog
All articlesDevelopers
E.164 regex: why a pattern is not enough to validate phone numbers
The common E.164 regex accepts impossible numbers. Real counterexamples, the shortest valid international numbers, and what to use a regex for instead.
7 min read
Developers
Phone number validation in JavaScript and Node.js
Validate phone numbers in JavaScript with libphonenumber-js: min vs max metadata, E.164, form input, and a live line-type check with runnable Node code.
8 min read
Developers
E.164 phone number format: a practical guide for developers
How to normalize phone numbers to E.164 in JavaScript and Python, the pitfalls that break deduplication, and how the MobileValidate API normalizes input.
8 min read


