One user, five spellings
Support opened a ticket: a user could not log in because "that phone number is already registered." The account in the database had 0171 5550134. The form submission had +49 171 5550134. Same German mobile. Two rows. The unique index on phone did nothing useful because we stored whatever the user typed.
That same number shows up in the wild as:
0171 5550134(national, trunk prefix)+491715550134(E.164-ish, no spaces)00491715550134(international exit code)49 (0) 171 5550134(country code plus explicit trunk)+49.171.555.0134(punctuation cosplay)
If you persist the raw string, uniqueness is fiction, OTP dedupe keys fragment, and "send code to the number on file" becomes a coin flip. You need one canonical form at the boundary, every time.
E.164 in one paragraph, honestly
E.164 is the ITU recommendation for the international public telecommunication numbering plan. The shape is simple: a leading +, then a country code (1–3 digits), then the subscriber number, for a maximum of 15 digits total after the +. No spaces, dashes, parentheses, or dots. +491715550134 is E.164. +49 171 5550134 is not. What E.164 does not tell you: whether the number is assigned, whether it is mobile, whether it can receive SMS, or whether it will ring. It is a formatting and addressing contract, not a reachability oracle. libphonenumber will not fill that gap either — it checks structure against national numbering plans, not live carrier state.
Never regex this: use libphonenumber
Google's libphonenumber (and its ports) encodes the numbering plans you will not keep current by hand. Parse with an explicit default region when the input lacks a + country code; format to E.164 for storage.
Python (phonenumbers):
import phonenumbers
from phonenumbers import NumberParseException
def to_e164(raw: str, default_region: str = "DE") -> str | None:
try:
num = phonenumbers.parse(raw, default_region)
except NumberParseException:
return None
if not phonenumbers.is_valid_number(num):
return None
return phonenumbers.format_number(
num, phonenumbers.PhoneNumberFormat.E164
)
print(to_e164("0171 5550134", "DE")) # +491715550134
print(to_e164("+49 171 5550134", "DE")) # +491715550134
print(to_e164("00491715550134", "DE")) # +491715550134
JavaScript (libphonenumber-js):
import { parsePhoneNumberFromString } from 'libphonenumber-js';
function toE164(raw, defaultCountry = 'DE') {
const num = parsePhoneNumberFromString(raw, defaultCountry);
if (!num || !num.isValid()) return null;
return num.format('E.164');
}
console.log(toE164('0171 5550134', 'DE')); // +491715550134
console.log(toE164('+49 171 5550134', 'DE')); // +491715550134
console.log(toE164('00491715550134', 'DE')); // +491715550134
Two predicates matter and they are not interchangeable:
is_valid_number/isValid()— the number is possible and matches a known length/pattern for that region. Use this on signup and OTP forms.is_possible_number/isPossible()— length only, against broad regional bounds. Use this when ingesting legacy CRM dumps where you would rather keep a maybe-number than drop the row.
Form validation wants valid. Bulk migration of ten-year-old CSV wants possible, plus a report of everything that failed even that bar. Do not ship a "phone validation regex." National plans change; your one-liner will not.
The gotcha gallery
These are numbers that hand-rolled normalizers mangle. Each line is input → wrong output you might emit → correct E.164.
Argentina mobiles insert a 9 after the country code for international dialing (and SMS gateways expect it).
Input: 11 2345-6789 with region AR (Buenos Aires mobile, national form).
Wrong: +541123456789 (missing the mobile 9).
Correct: +5491123456789.
libphonenumber's AR rules add the 9 when the number is typed as mobile; a strip-non-digits routine will not.
Mexico dropped the trunk 1 after country code 52 for mobiles in 2019.
Input (legacy stored value): +5215512345678.
Wrong if you "trust stored E.164 forever": keep +5215512345678 and watch modern routes reject or misroute it.
Correct today: +525512345678.
Re-parse legacy rows; do not assume yesterday's E.164 string is still the dialable form.
UK numbers written with an explicit trunk zero inside parentheses.
Input: +44 (0)7911 123456.
Wrong: +4407911123456 (naively deleting punctuation but keeping the 0).
Correct: +447911123456.
The (0) is a national trunk prefix reminder for domestic dialing; it must not appear after +44.
Trunk-prefix 0 means different things by country.
German input 0171 5550134 → correct +491715550134 (drop the leading 0 after applying country code 49).
Italian landline input 06 12345678 (Rome) → correct +390612345678 (the leading 0 is significant and stays part of the national number).
Wrong generic rule "always strip one leading zero after the country code" produces +39612345678 for the Italian case — a different, invalid number. Region-aware parse is the only safe path.
Italian landlines keep the leading zero; mobiles do not share the same shape.
Input: 02 1234567 region IT.
Wrong (strip-all-leading-zeros policy): +3921234567.
Correct: +39021234567.
If your normalizer is a pair of regex replacements, you will get this wrong for at least one of DE/IT/AT/CH on day one.
Leading zeros, spreadsheets, and other data-loss machines
Phone numbers are not integers. Store them as integers and you lose the leading +, every leading zero, and any chance of round-tripping national forms. A column typed as BIGINT turns +491715550134 into 491715550134 and 01632 960775 into 1632960775 — both irreversible without guessing the region.
CSV is worse when Excel touches it. Open a export containing +447911123456, save, re-upload: you get 4.47911E+11 or 447911123456. The + is gone. Leading zeros on national numbers are gone. Downstream parse then guesses wrong region or fails is_valid_number.
Rules that survive contact with finance teams:
- Column type
TEXT/VARCHAR(at least 16 chars for E.164, more if you also keep a display field). - Always persist the canonical value with the leading
+. - Quote the field in CSV (
"+447911123456") and prefer.xlsxwith an explicit text column when non-engineers will open the file. - Never cast through numeric types in ETL "for cleanup."
Validation UX that doesn't lose signups
Canonical storage is a backend concern. The form still has to accept how humans type.
- Put a country selector next to the input. That selection is the
default_region/defaultCountryyou pass to parse. Do not infer country only fromAccept-Languageor IP; roaming users exist. - Run
AsYouTypeFormatter(Python:phonenumbers.AsYouTypeFormatter; JS:AsYouTypefromlibphonenumber-js) on input so0171555…becomes a readable national pattern for the chosen country while the user types. - Validate on blur, not on every keystroke with a hard error. Empty-while-typing is not an error.
- On failure, show the expected national example for that country (
phonenumbers.example_number_for_type(region, PhoneNumberType.MOBILE)/getExampleNumber), not a generic "invalid phone." - Never block paste. Users paste from contacts, WhatsApp, and email signatures. Strip formatting on paste, then re-run the as-you-type formatter; do not
preventDefaultonpaste.
Sketch for the blur path in JS:
import parsePhoneNumberFromString, { AsYouType } from 'libphonenumber-js';
const input = document.querySelector('#phone');
const country = () => document.querySelector('#country').value;
input.addEventListener('input', () => {
const formatter = new AsYouType(country());
input.value = formatter.input(input.value);
});
input.addEventListener('blur', () => {
const num = parsePhoneNumberFromString(input.value, country());
if (!num || !num.isValid()) {
input.setCustomValidity(
`Use a valid number for ${country()}, e.g. the national format for that country.`
);
} else {
input.setCustomValidity('');
// send num.format('E.164') to the API
}
});
The API accepts only E.164. The client may show national format. Those are different layers; do not store what the input visually contains at submit time without parsing first.
Type detection and what it's worth
libphonenumber can classify a parsed number:
import phonenumbers
from phonenumbers import phonenumberutil
num = phonenumbers.parse("+491715550134", None)
print(phonenumberutil.number_type(num))
# PhoneNumberType.MOBILE
import { parsePhoneNumberFromString } from 'libphonenumber-js';
const num = parsePhoneNumberFromString('+491715550134');
console.log(num.getType()); // 'MOBILE'
Useful values: MOBILE, FIXED_LINE, FIXED_LINE_OR_MOBILE, VOIP, TOLL_FREE, and a handful of others. Practical use: a soft signal before you fire an OTP SMS — if the type is clearly FIXED_LINE or TOLL_FREE in a region where that distinction is reliable, warn the user and offer voice call instead of silently failing delivery.
Unreliable cases you must not hard-block on:
- United States and Canada: huge slices land in
FIXED_LINE_OR_MOBILEbecause the plan does not cleanly separate them; number portability makes yesterday's landline a mobile. - Ported numbers anywhere: the library's type is plan metadata, not current carrier assignment.
- VOIP and virtual ranges: often valid, sometimes SMS-capable, sometimes not — structure alone cannot decide.
libphonenumber does not verify reachability. It has never claimed to. Treat number_type as a UX hint, not an authorization gate. In practice I normalize to E.164 at the edge and let the SMS API I send through reject the truly undeliverable ones, logging its per-number error codes rather than trying to pre-guess carrier reachability myself.
The storage and comparison contract
Contract:
- Store a single E.164 string (
TEXT, including+). - Compare E.164 strings only (
a.phone = b.phone, hash sets of E.164 for OTP cooldown keys). - Display at the edge with national or international formatting — never re-format on the way into the database.
import phonenumbers
from phonenumbers import PhoneNumberFormat, NumberParseException
def display_national(e164: str) -> str:
num = phonenumbers.parse(e164, None)
return phonenumbers.format_number(num, PhoneNumberFormat.NATIONAL)
Migration for a table already full of free-text junk: parse, write E.164 into a new column, and report failures instead of deleting rows.
import csv
import phonenumbers
from phonenumbers import PhoneNumberFormat, NumberParseException
def migrate_row(raw: str, default_region: str) -> tuple[str | None, str | None]:
"""Returns (e164, error_reason)."""
if raw is None or not str(raw).strip():
return None, "empty"
try:
num = phonenumbers.parse(str(raw).strip(), default_region)
except NumberParseException as e:
return None, f"parse: {e.error_type.name}"
if not phonenumbers.is_possible_number(num):
return None, "not_possible"
# Prefer valid; still keep possibles for manual review
e164 = phonenumbers.format_number(num, PhoneNumberFormat.E164)
if not phonenumbers.is_valid_number(num):
return e164, "possible_but_not_valid"
return e164, None
# Example batch with a side-car report
report_path = "phone_migration_report.csv"
with open(report_path, "w", newline="", encoding="utf-8") as report:
writer = csv.writer(report)
writer.writerow(["id", "raw", "e164", "status"])
for row_id, raw, region in load_legacy_rows(): # your DB cursor
e164, err = migrate_row(raw, region or "US")
if err == "possible_but_not_valid":
writer.writerow([row_id, raw, e164, err])
save_e164(row_id, e164) # keep, flag for review
elif err:
writer.writerow([row_id, raw, "", err])
# do not save — leave old value, page a human
else:
writer.writerow([row_id, raw, e164, "ok"])
save_e164(row_id, e164)
Unparseable rows stay in the report with a reason (EMPTY, INVALID_COUNTRY_CODE, NOT_A_NUMBER, TOO_SHORT_NSN, TOO_LONG — those names are the NumberParseException error types). Silent drops create support tickets you cannot reconstruct.
Checklist
- [ ] Parse with libphonenumber (or port), never a hand-rolled regex
- [ ] Pass an explicit default region when input lacks
+ - [ ] Persist E.164 only (
TEXT/VARCHAR, leading+kept) - [ ] Uniqueness, OTP cooldown, and account lookup keys use the E.164 string
- [ ] Forms: country selector → region; AsYouType on input; validate on blur; paste allowed
- [ ]
is_valid_numberon interactive signup;is_possible_numberonly for legacy ingest - [ ]
number_typeis a soft warning before SMS, not a hard block - [ ] Do not expect libphonenumber to confirm deliverability or carrier
- [ ] Argentina mobiles: expect
+54 9 …; Mexico: re-parse pre-2019+521…values - [ ] UK
+44 (0)…drops the trunk0; Italian landlines keep their leading0 - [ ] Migrate free-text columns with a failure report, not
UPDATE … WHERE parsed IS NULLdeletes - [ ] Format national/international only in UI or notification templates