Developers

Phone number validation in Python: phonenumbers, Pydantic and a live check

Validate phone numbers in Python with the phonenumbers library: is_valid vs is_possible, E.164, a Pydantic validator, pytest and a live check with httpx.

By Published 7 min read

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.

Shell
pip install phonenumbers==9.0.40 httpx pydantic

The 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.

A terminal sends an API request and a JSON response returns true, false and null values.A terminal sends an API request and a JSON response returns true, false and null values.
One REST call. Every answer is true, false or null (unknown).

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.

Python
# 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:

CallResult
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:

Python
# 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: E164Phone

SignUp(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:

Python
# 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 out

With 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:

Python
{'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_type is mobile: SMS is a sensible channel. Store line_type with checked_at.
  • fixed_line, toll_free or voip: don't send SMS. Offer voice or e-mail, or add a verification step if your fraud rules call for it.
  • status is unknown: keep your default behaviour and retry later. Unknown answers aren't billed. None is missing data, so never store it as False.
  • number_status is duplicate or invalid_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:

Python
# 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 (or phonenumberslite) 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 AfterValidator gives 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 None and unknown as missing data, and test every branch for free with test numbers.

Sources

  1. python-phonenumbers — GitHub (David Drysdale), 2026
  2. phonenumbers on PyPI — Python Package Index, 2026
  3. Validators — Pydantic, 2026
  4. 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.

All articles

Know before you send.

Tell us about your use case. We review every request and set you up with test and live keys.