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

Canonical: https://mobilevalidate.com/blog/phone-number-validation-python · Last updated: 2026-09-25

![A typed model with a phone field tagged E.164, a test run of 24 dots with 23 passed, an HTTP call returning JSON and three result rows.](https://mobilevalidate.com/images/blog/phone-number-validation-python.svg)

*Validate in the model, test with pytest, and add the live check over HTTP.*


By MobileValidate team (https://mobilevalidate.com/about) · Published: 2026-09-25 · Category: Developers · Tags: Phone validation, Python, Libphonenumber, Pydantic, E.164, API

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`](https://github.com/daviddrysdale/python-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.

```bash
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](/blog/e164-regex-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.

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

| 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](/blog/mobile-number-portability-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`](https://pypi.org/project/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](/services/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](/docs/errors) 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](/docs/test-mode), 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](https://github.com/daviddrysdale/python-phonenumbers) — GitHub (David Drysdale), 2026
2. [phonenumbers on PyPI](https://pypi.org/project/phonenumbers/) — Python Package Index, 2026
3. [Validators](https://docs.pydantic.dev/latest/concepts/validators/) — Pydantic, 2026
4. [libphonenumber release notes](https://github.com/google/libphonenumber/blob/master/release_notes.txt) — 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.
