RoutexAPI

Phone numbers

Validating and normalising phone numbers to E.164 — and the cases libraries get wrong

Turn messy user-entered phone numbers into clean E.164 with libphonenumber in Node and Python, understand what 'valid' really means, and see exactly where offline validation ends and carrier/line-type lookups begin.

The RoutexAPI TeamUpdated August 11, 20267 min read

Store a phone number as the user typed it and you have signed up for a lifetime of duplicates, failed SMS sends, and "why can't I log in" tickets. (415) 555-2671, +1 415-555-2671, 415.555.2671 and 4155552671 are one number wearing four costumes. The fix is to normalise every number to a single canonical form on the way in, and to be precise about what "valid" means — because most of the mistakes here come from asking a library a question it cannot answer and trusting the reply.

E.164 is the canonical form

E.164 is the international standard for phone number formatting: a leading +, the country calling code, then the national number, no spaces or punctuation, maximum fifteen digits. +14155552671. That is what you store. It is unambiguous, it is what SMS and voice providers expect, and it makes a phone number a reliable unique key.

The catch is that you cannot get to E.164 from 415-555-2671 without knowing the country, because the same national number exists in dozens of countries. That country context is the single most important input to phone validation, and the most common source of bugs.

Use libphonenumber — do not write your own regex

Google's libphonenumber encodes the numbering plans of every country: valid lengths, prefixes, and formatting rules, updated as carriers change them. A regex cannot keep up with that and will reject valid numbers and accept invalid ones. Every serious language has a maintained port.

Node

import { PhoneNumberUtil, PhoneNumberFormat } from "google-libphonenumber";

const util = PhoneNumberUtil.getInstance();

export function toE164(input, defaultRegion) {
  try {
    const parsed = util.parse(input, defaultRegion); // region e.g. "US"
    if (!util.isValidNumber(parsed)) return null;
    return util.format(parsed, PhoneNumberFormat.E164);
  } catch {
    return null; // unparseable input
  }
}

toE164("(415) 555-2671", "US"); // "+14155552671"
toE164("+44 20 7946 0958", "US"); // "+442079460958" — explicit + wins over region
toE164("not a phone", "US"); // null

Python

import phonenumbers

def to_e164(raw: str, default_region: str) -> str | None:
    try:
        parsed = phonenumbers.parse(raw, default_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("(415) 555-2671", "US")   # "+14155552671"
to_e164("020 7946 0958", "GB")    # "+442079460958"

Both ports share the same model, so the concepts below transfer between them.

The default region is the whole game

parse needs a default region for any input that does not start with +. Get it wrong and you get one of two failure modes:

  • A valid number rejected. 020 7946 0958 parsed with region "US" is invalid — the US numbering plan has no such number — even though it is a perfectly good London landline.
  • A valid number accepted as the wrong country, silently mapping a real number to the wrong E.164 string.

So where does the region come from? In order of reliability: an explicit country selector next to the phone field; the user's stored account country; the IP-geolocated country as a last resort. What you must not do is hardcode "US" and hope. If your users are international, make the country field explicit and visible — it is cheaper than the support tickets.

isValid vs isPossible — two different questions

libphonenumber exposes two checks and they mean different things:

  • isPossibleNumber — is this the right length for a number of this type in this country? Cheap, lenient, good for inline "you're missing a digit" feedback as the user types.
  • isValidNumber — does this match an actual assigned numbering range? Stricter, and the one you gate storage on.

Use possible-checks for live UX and valid-checks for the final decision. Do not use isPossible as your storage gate; it will wave through numbers that are the right length but in an unassigned range.

What libphonenumber does NOT know — and where the myths are

This is the part that separates a working integration from a confidently-wrong one. libphonenumber is an offline description of numbering plans. It does not phone anyone. So there is a hard ceiling on what it can tell you:

  • Is this number currently in service? Unknown. A number can be valid and assigned to a range but disconnected. Offline data cannot see the network.
  • Is it mobile or landline? Sometimes. In countries where mobile and fixed lines occupy separate prefix ranges (much of Europe), getNumberType() is reliable. In countries with overlaid ranges — notably the US and Canada, where +1 mobile and landline share the same area codes — getNumberType() returns FIXED_LINE_OR_MOBILE, meaning "I can't tell from the prefix." Code that assumes it can distinguish mobile from landline in the US is wrong, and it is wrong quietly.
  • Which carrier owns it? Not from isValidNumber. There is a separate phonenumbers.carrier mapping in the Python port, but it returns the carrier the prefix was originally allocated to, which number portability makes routinely stale — a number ported from one carrier to another still maps to the original. For a real "who carries this right now" answer you need live network data (an HLR lookup or a portability-aware database), which is not something any offline library ships.
  • Line type nuance — VoIP, toll-free, premium-rate — is partially encoded (TOLL_FREE, PREMIUM_RATE, VOIP exist) but VoIP detection in particular is incomplete, because VoIP providers draw from ordinary ranges.

The honest boundary

QuestionOffline libphonenumberNeeds a live data source
Is it well-formed / E.164-able?Yes
Is it a valid assigned range?Yes (isValidNumber)
Mobile vs landlineOnly where ranges are separateUS/CA and other overlay plans
In service right nowNoHLR / live lookup
Current carrier (post-porting)No (stale prefix map only)Portability-aware lookup
Roaming / active statusNoHLR lookup

If your requirement stops at "clean, deduplicated, storable numbers", offline validation is the entire answer and you are done. If it extends to "don't send SMS to a landline" or "flag disposable-VoIP signups", you have crossed into live-lookup territory, and no amount of libphonenumber will get you there.

A normalisation function you can actually ship

import phonenumbers
from phonenumbers import PhoneNumberType

def normalise(raw: str, region: str) -> dict | None:
    try:
        p = phonenumbers.parse(raw, region)
    except phonenumbers.NumberParseException:
        return None
    if not phonenumbers.is_valid_number(p):
        return None
    ntype = phonenumbers.number_type(p)
    return {
        "e164": phonenumbers.format_number(p, phonenumbers.PhoneNumberFormat.E164),
        "country": phonenumbers.region_code_for_number(p),
        "type": ntype.name,  # MOBILE / FIXED_LINE / FIXED_LINE_OR_MOBILE / ...
        # Note: FIXED_LINE_OR_MOBILE means "can't tell" — treat it as unknown, not as landline.
    }

Store e164 as your canonical key, keep country for display formatting, and treat type as advisory — never branch on it for US numbers as if it were authoritative.

Format for storage, format again for display

E.164 is the right thing to store and a poor thing to show. +14155552671 is unambiguous but hard to read, and it is not how anyone in that country writes their own number. So keep the canonical E.164 in the database and render a human format at the edge, derived from the same parsed object:

import phonenumbers
from phonenumbers import PhoneNumberFormat

p = phonenumbers.parse("+14155552671", None)  # + present, so no region needed
phonenumbers.format_number(p, PhoneNumberFormat.NATIONAL)         # "(415) 555-2671"
phonenumbers.format_number(p, PhoneNumberFormat.INTERNATIONAL)    # "+1 415-555-2671"

Use the national format when you already know the viewer is in the number's country, and the international format otherwise — a French user looking at a US contact wants to see the +1. There is also a format_out_of_country_calling_number helper that formats a number as dialled from a given country, which is the correct choice for click-to-call. The principle: store one canonical form, and treat every displayed form as a view computed from it, never as a second thing you persist.

Storing and indexing

Because E.164 is a single canonical string, it makes an excellent unique key — but only if you enforce that. Put a unique constraint (or a unique index) on the stored E.164 column so two costumes of the same number cannot both land as separate rows, and normalise before the uniqueness check, not after. A frequent bug is validating and storing the raw input, then adding deduplication later and discovering the "same" number exists five times in four formats. Normalise on write, index the normalised column, and the duplicate class disappears at the source. If you also need to search by the national number without the country code, store that as a separate derived column rather than stripping the + at query time.

Self-hosting

For validation and normalisation, self-hosting is the default and it is genuinely free: libphonenumber is a library you already vendored, the data ships with it, and you update it when you bump the version. There is no server to run. Do turn on a scheduled dependency bump so the numbering-plan data does not rot, and pick the default region deliberately.

Where self-hosting stops being an option is the live tier — HLR lookups, portability-aware carrier data, active-line checks. That data is not a library; it is a commercial feed with per-query cost, negotiated access, and its own SLA. That is the natural line: keep formatting and validation in your own process where they cost nothing, and reach for a managed lookup only for the questions the offline data physically cannot answer.

Try it on RoutexAPI

Skip the setup

Would rather not run and maintain this yourself? These APIs on RoutexAPI do the same job behind one key.