# How to normalize phone numbers to E.164

_2026-08-12 · Boundstone (https://boundstone.io/blog/how-to-normalize-phone-numbers-e164)_


If you want to normalize phone numbers to E.164, the job is smaller than it looks: parse whatever a human typed, resolve it against the right country, and write out the single canonical `+<country><national>` form. Do that once, at the moment of capture, and every downstream system — your database, your CRM, your SMS provider — gets one shape instead of nine. This post shows the normalization step in JavaScript and Python using libphonenumber, and it draws a hard line at the end: a clean E.164 string tells you the number is well-formed, not that anyone will answer it.

## Why normalize once and store E.164

E.164 is the international format: a leading `+`, a country code, then the national number, no spaces or punctuation — `+16504472983`. It is unambiguous, it is what every telephony API expects on the wire, and it is the only format worth putting in a column. If you want the full background on the standard itself, see [what is E.164](/blog/what-is-e164).

The mistake is storing what the user typed. `(650) 447-2983`, `650-447-2983`, and `650.447.2983` are the same number and three different strings, so your `WHERE phone = ?` lookups miss, your dedupe fails, and your uniqueness constraint lets the same customer sign up twice. Normalize at the boundary — one function, run once when the value comes in — and store the result. Everything after that reads a canonical value.

## The messy-input problem, and the default region

Humans type phone numbers the way they say them. You will receive spaces, dashes, parentheses, dots, a leading `+`, a leading `00`, a domestic trunk `0`, and the occasional stray letter. libphonenumber handles the punctuation for you; you do not need to strip anything first.

The one thing the library cannot guess is the country when the input has no `+`. `447-2983` is meaningless on its own — it could belong to any country's numbering plan. So you supply a **default region** (an ISO country code like `US` or `GB`). The rule is simple:

- Input already in international format (`+44 20…`) — the default region is ignored.
- Input in national format (`020 7946…`) — the default region tells the parser which plan to interpret it against.

Pick the default region from context you already have: the user's account country, an IP-based guess, or a form's country selector. Do not hardcode `US` and hope.

## Normalize in JavaScript with libphonenumber-js

Install `libphonenumber-js`. The E.164 string lives on the `.number` property of a parsed number.

```js
import { parsePhoneNumber } from 'libphonenumber-js'

// International format — default region not needed.
parsePhoneNumber('+1 (650) 447-2983').number
// => '+16504472983'

// National format — supply the region the number belongs to.
parsePhoneNumber('(650) 447-2983', 'US').number
// => '+16504472983'

// Leading trunk zero, spaces — normalized away.
parsePhoneNumber('020 7946 0018', 'GB').number
// => '+442079460018'
```

`parsePhoneNumber` throws on input it cannot parse, so wrap it. Gate on `isValidPhoneNumber` first and you get a function that returns a canonical string or `null` — never an exception mid-request:

```js
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js'

function toE164(input, defaultCountry) {
  if (!isValidPhoneNumber(input, defaultCountry)) return null
  return parsePhoneNumber(input, defaultCountry).number
}

toE164('650.447.2983', 'US') // => '+16504472983'
toE164('not a phone', 'US')  // => null
```

## Normalize in Python with phonenumbers

The Python port is `phonenumbers`. Parse against a region, check validity, then format with `PhoneNumberFormat.E164`.

```python
import phonenumbers

def to_e164(raw, region=None):
    try:
        parsed = phonenumbers.parse(raw, region)
    except phonenumbers.NumberParseException:
        return None
    if not phonenumbers.is_valid_number(parsed):
        return None
    return phonenumbers.format_number(
        parsed, phonenumbers.PhoneNumberFormat.E164
    )

to_e164('(650) 447-2983', 'US')  # '+16504472983'
to_e164('020 7946 0018', 'GB')   # '+442079460018'
to_e164('+1 650 447 2983')       # '+16504472983'  (region not needed)
```

`phonenumbers.parse` raises `NumberParseException` on garbage input, which is why the `try/except` is not optional. For a fuller walkthrough of validation on the Python side, see [validate phone numbers in Python](/blog/validate-phone-python).

## What normalization proves — and what it doesn't

Here is the line, drawn plainly. Running libphonenumber confirms three things: the **format** parses, the number matches a known **region**, and the metadata gives you a **line type** (mobile, fixed line, or an either-or when the plan doesn't separate them). That is genuinely useful, and for most forms it is all you need. `is_valid_number` will reject a US number with too few digits or an area code that was never assigned.

What it does **not** prove is that the number is live. Metadata is a static description of a numbering plan. It cannot tell you the number was assigned to a subscriber, that the SIM is switched on, or that it was ported to a different carrier last week. That question — reachability — is a carrier or HLR lookup, and it is a different, paid, network operation.

Boundstone's phone endpoint is honest about exactly this split. Every response carries a `checks` object:

```json
{
  "valid": true,
  "e164": "+16504472983",
  "country": "US",
  "line_type": "fixed_line_or_mobile",
  "checks": {
    "performed": ["format", "region", "line_type_metadata"],
    "not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
  }
}
```

Boundstone does not perform `carrier_lookup`, `ported_status`, or `hlr_liveness` by default — they run as a paid opt-in when you send `hlr:true`, at 5 credits, refunded when the network cannot answer. The `not_performed` list is not an apology; it is the point. It means a `valid: true` from us describes precisely what was checked and refuses to imply the rest. If you want the same libphonenumber-grade normalization without wiring up the library, the keyless [phone validator tool](/tools/phone-validator) does it for you — paste a number, no signup, no key.

## The short version

- Normalize once, at capture, and store the E.164 string — not what the user typed.
- Use `parsePhoneNumber(input, region).number` in JavaScript (`libphonenumber-js`) or `phonenumbers.format_number(parsed, PhoneNumberFormat.E164)` in Python.
- Supply a **default region** for any input without a `+`; it's ignored when the input is already international.
- Both libraries throw on bad input — gate on `isValidPhoneNumber` / `is_valid_number` and return `null`, don't let an exception escape.
- Normalization proves shape, region, and plan-validity. It does **not** prove the number is reachable — that's carrier/HLR, which Boundstone lists in `not_performed` and does not do yet.
