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.

Phone number round trip through E.164 A reader types a number in national format, the application parses it with the selected country to produce the canonical E.164 form, that form is stored, and it is rendered back in national format for display. One number, three forms Typed 030 12345678 parse + country Canonical +493012345678 store Rendered 030 12345678 Storage is always E.164; input and display are national.
The country is required to parse and irrelevant to store: E.164 already contains it.

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.

Five phone number assumptions and what is actually true Phone numbers range from four to fifteen digits depending on the country. Area codes vary in length and sometimes do not exist. A leading zero is the national trunk prefix rather than a formatting artifact. A country code can be shared by many territories, as plus one is. And input routinely contains spaces, hyphens, parentheses and extensions. Assumptions that break outside one country Reality Numbers are 10 digits 4 to 15 digits, by country Area codes are 3 digits 1 to 5 digits, sometimes none A leading zero is noise it is the national trunk prefix — dropped only when the country code is added One number, one country shared country codes: +1 covers twenty-odd territories Digits only formatting characters and extensions are common in input
The third row causes the most corrupted data: stripping the leading zero without adding a country code loses information.

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.

Five steps for phone input and storage The country is collected or defaulted because parsing requires it. Parsing uses a maintained library rather than a pattern, since numbering plans change. Validation checks the number type the feature needs, such as mobile for text messages. The canonical E.164 string is stored with no separators and any extension held separately. And display is formatted from the stored value, nationally for local readers and internationally for everyone else. Handling a phone number correctly 1 Ask for the country, or default it from the address parsing needs it; storage does not 2 Parse with a maintained library the rules change; a regex cannot keep up 3 Validate for the number type you need mobile for SMS, any for contact 4 Store E.164 only no separators, no extension in the same field 5 Render national at home, international abroad formatted from the stored value
Steps two and four are the durable ones — everything else can be added later without a migration.

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.

Part of Internationalizing Forms & Input.