Phone number input and E.164 storage
A German customer enters 030 12345678. The application strips the spaces, drops the leading zero because “numbers do not start with zero”, and stores 3012345678. Nobody can call them, the SMS gateway rejects the number, and the record cannot be repaired without asking the customer again — the country and the trunk prefix are both gone.
Phone numbers look like digits and are a structured identifier with a country-specific grammar.
Root cause: national format is ambiguous without its country
A number written the way a person writes it locally is incomplete: 030 12345678 is a Berlin number in Germany and something else, or nothing, elsewhere. The leading zero is the German national trunk prefix — the digit you dial before an area code within the country — and it is removed only when the number is rewritten with a country code, becoming +49 30 12345678.
Code that treats the number as a string of digits loses two things at once. Stripping the zero without adding the country code destroys information, and storing a national-format string without recording which country it belongs to leaves a value that cannot be dialled from anywhere else.
E.164 is the standard that resolves it: a plus sign, a country calling code, and the subscriber number, with no separators and a maximum of fifteen digits. It is unambiguous, globally dialable, and the format every telephony provider expects.
The rules themselves are not stable enough to hardcode. Numbering plans change: countries add digits, introduce new mobile prefixes and occasionally renumber whole regions. A regular expression written today is wrong within a few years, which is why this is one of the few areas where a maintained library is clearly the right answer.
Minimal reproducible example
const raw = '030 12345678'; // a Berlin landline
raw.replace(/\D/g, ''); // '03012345678' — country unknown
raw.replace(/\D/g, '').replace(/^0/, ''); // '3012345678' — trunk prefix destroyed
// Neither value can be dialled, and neither records that it is German.
The fix: parse with the country, store E.164
import { parsePhoneNumberFromString } from 'libphonenumber-js';
export function normalizePhone(input: string, country: CountryCode) {
const parsed = parsePhoneNumberFromString(input, country);
if (!parsed || !parsed.isValid()) return { ok: false as const };
return {
ok: true as const,
e164: parsed.number, // '+493012345678' — what you store
national: parsed.formatNational(), // '030 12345678' — what a local reader sees
international: parsed.formatInternational(),
type: parsed.getType(), // 'MOBILE' | 'FIXED_LINE' | …
};
}
// Display: national at home, international abroad.
export function displayPhone(e164: string, readerCountry: CountryCode) {
const p = parsePhoneNumberFromString(e164)!;
return p.country === readerCountry ? p.formatNational() : p.formatInternational();
}
The country parameter is needed only for parsing, because national format is what the reader typed. Once the number is E.164 it carries its own country and the parameter is never needed again — which is why storing E.164 makes every downstream consumer simpler.
Validating for what you will actually do with it
“Valid” is not one property. A number can be a well-formed, assignable number and still be the wrong kind for the feature asking for it.
Any contact number needs only validity. A landline is a perfectly good number to store for a customer record.
A number that will receive text messages must be a mobile, and getType() answers that for most countries. Validating this at entry is far better than discovering it when a one-time code silently fails to arrive — a failure the customer experiences as your login being broken.
A number that will receive automated voice calls has the opposite constraint in some countries, where certain ranges are premium-rate or non-routable.
Type detection is reliable in countries with separated mobile ranges and unreliable in those without — notably the United States and Canada, where mobiles and landlines share the same ranges and no library can distinguish them. A product depending on SMS delivery there needs a verification step rather than a validation rule, which is the correct answer everywhere: sending a code is the only way to know a number both exists and reaches the person.
Migrating a column of national-format numbers
Most products arrive at E.164 with a table already full of numbers stored in whatever the reader typed. The migration is possible and partly lossy, and the ordering determines how much is recoverable.
Start writing E.164 immediately, in a new column, for every new and updated record. This stops the problem growing while the old data is worked through, and it means the backfill has a stable target.
Backfill the rows where the country is knowable. Most records carry an address, a billing country or an account region, and parsing the stored string with that country recovers a valid E.164 number for the large majority. Doing it in batches with the result recorded per row — parsed, ambiguous, or failed — turns the migration into a report rather than an all-or-nothing script.
Treat the remainder honestly. A number stored without separators, without a leading zero and without any country signal cannot be reconstructed: several countries could produce it, and guessing is worse than admitting the gap. Those rows need the customer to re-enter the number, which is a product decision about when to ask — at next login, at next order, or in a campaign.
Do not delete the original strings. Keep the raw value in a column alongside the canonical one until the migration is closed out. It costs nothing and it is the only evidence available if a parse turns out to have been wrong for a whole country.
The expected shape of the outcome is worth setting in advance: on a typical dataset the large majority parses cleanly, a small fraction is ambiguous, and a small tail is unrecoverable. Knowing that before starting keeps the unrecoverable tail from being treated as a failure of the migration.
Verification
test.each([
['DE', '030 12345678', '+493012345678'],
['DE', '+49 30 12345678', '+493012345678'],
['GB', '020 7946 0958', '+442079460958'],
['US', '(415) 555-2671', '+14155552671'],
['NG', '0803 123 4567', '+2348031234567'],
])('%s %s normalises to %s', (country, input, expected) => {
expect(normalizePhone(input, country)).toMatchObject({ e164: expected });
});
test('a number already in E.164 needs no country', () => {
expect(normalizePhone('+493012345678', 'US')).toMatchObject({ e164: '+493012345678' });
});
The last assertion matters more than it looks: a customer pasting an international number into a form defaulted to another country must not have it reinterpreted. A leading plus means the number is already global, and the country hint should be ignored.
When to escalate
If valid numbers are rejected, check the library version. Numbering plans change and the metadata is updated with releases, so a pinned dependency several years old rejects ranges that have since been allocated.
If numbers are accepted and messages do not arrive, the number type is the likely cause where detection is possible, and a verification step is the answer where it is not.
If stored numbers are inconsistent, some path is writing without normalising — an import, an admin tool, a webhook. The normalisation belongs in one function every writer calls, the same argument as the identity key in duplicate usernames from Unicode normalization.
FAQ
Should the input mask the number as it is typed?
Live formatting is pleasant and easy to get wrong — it fights the reader mid-entry, particularly when they paste an international number into a field expecting national format. Formatting on blur, and accepting anything on the way in, is the safer default.
What about extensions?
Store them separately. E.164 has no representation for an extension, and appending one to the number produces a string that fails validation everywhere. A separate nullable column, rendered after the number, is the arrangement every telephony provider expects.
Do I need the country selector if I already collect an address?
Not necessarily — defaulting the phone country from the address country is a good guess and correct most of the time. It must remain overridable, because people routinely have a number from a country they no longer live in.
Is the plus sign worth storing?
Yes. E.164 is defined with it, providers expect it, and storing bare digits means every consumer has to know to add it back. It costs one character and removes an entire class of integration bug.
How should the country selector next to the phone field behave?
As a hint rather than a gate. The selector supplies the country used for parsing, so it needs to be visible and changeable, and it must be overridden automatically when the reader types a number beginning with a plus. A selector that forces a national interpretation onto an explicitly international number is the most common way a correct input is rejected.
Does E.164 work for short codes and service numbers?
No, and that is a genuine limitation rather than an implementation gap. Short codes, emergency numbers and some service numbers are national concepts with no international form, so they cannot be stored as E.164. If a product needs them, they belong in a separate field with the country recorded explicitly — trying to force them into the same column is how a validation rule ends up loose enough to accept anything.
Related
- Internationalizing Forms & Input — canonical storage as a general rule.
- Address form field order by country — the country selector this field depends on.
- Parsing numbers typed with a comma decimal — the other input where the platform gives you no parser.
- Date & Number Formatting Standards — formatting values back out for display.
Part of Internationalizing Forms & Input.