How to normalize phone numbers to E.164
Parse messy human input with libphonenumber, supply a default region, and store one canonical shape — while staying honest about what a clean E.164 string does and doesn't prove.
Contents
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.
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.
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:
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.
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.
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:
{
"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 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).numberin JavaScript (libphonenumber-js) orphonenumbers.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_numberand returnnull, 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_performedand does not do yet.