← Blog

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

Frequently asked questions

What is E.164 phone number format?

E.164 is the international telephone numbering standard: a leading plus sign, the country calling code, and the national subscriber number, written with no spaces, dashes, or parentheses (for example, +16504472983). The whole string is at most 15 digits after the plus. Storing numbers this way makes them unambiguous worldwide and safe to compare, deduplicate, or hand to messaging and telephony APIs.

Why do I need to know the country to normalize a phone number to E.164?

A national-format number like (650) 447-2983 or 020 7946 0018 doesn't carry its country code, so the same digits can be valid in more than one country. To normalize reliably you either need the number already in international form starting with a plus sign, or a default region to interpret it against. Boundstone's phone check parses and validates against the region you supply and returns the E.164 string plus the detected country, so you aren't guessing which country the digits belong to.

Does a valid E.164 number mean the phone is real or active?

No. A valid result means the number is correctly formatted and structurally plausible for its country, and Boundstone also returns line-type metadata such as mobile or fixed line. It does not confirm the number is currently in service, reachable, or tied to a specific carrier. Boundstone does not perform carrier lookup, ported-status, or HLR liveness checks by default (they're a paid opt-in via hlr:true, 5 credits), and every response lists those under checks.not_performed, so you know exactly what a valid does and does not prove before you rely on it.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist