# How to validate a phone number in Python

_2026-07-24 · Boundstone (https://boundstone.io/blog/validate-phone-python)_


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:

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

```python
import phonenumbers

number = phonenumbers.parse("+16504472983", None)
```

If your input is a national format without the `+`, give `parse` the region to interpret it against:

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

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

```python
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](/blog/what-is-e164).) If you also do this in a Node service, the JavaScript walkthrough is [validating phone numbers in JavaScript](/blog/validate-phone-javascript) — same library, same rules, so your two stacks agree.

## number_type is metadata — and it can be stale

`phonenumbers` also classifies a number:

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

```python
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).

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

```json
{
  "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`, `carrier` and `geocoder` are static metadata.** Treat `carrier`/`geocoder` as 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:true` rather than faking.
- **Kick the tires without a key** at the free [phone validator](/tools/phone-validator).
