Skip to main content

Phone Number Standards

From the CTO — Required reading for all product and engineering teams. This is not a suggestion. Every system that touches a phone number must follow this guide.


Table of Contents

  1. Why This Matters
  2. The Standard: E.164
  3. Official Tooling
  4. Database Best Practices
  5. Quick Reference

Why This Matters

Phone numbers look simple. They are not.

Users enter numbers in dozens of formats. Every country has different rules. Telephony providers like Twilio are strict. When we get this wrong, real things break.

What Goes Wrong Without a Standard

SMS delivery fails silently. Twilio and other providers only accept E.164 format. A number stored as (415) 555-0100 will not send. The API call will fail or be silently dropped. The user never receives the message and we have no idea why.

Costs increase. Duplicate records are created when the same number is stored in multiple formats (+14155550100, 415-555-0100, 14155550100). We send duplicate SMS messages and pay for each one. Deduplication queries become expensive or impossible.

Database indexes break. A UNIQUE index on a phone column only works if the data is normalized. If +14155550100 and 14155550100 are both in the database, we have a data integrity problem that is very hard to fix retroactively.

Downstream systems fail in unexpected ways. Analytics, CRM exports, compliance reports — all of these assume a consistent format. Mixed formats cause silent data corruption at reporting time, not at write time. By the time we notice, the damage is widespread.

Architectural warning: Data normalization bugs are write-time bugs with read-time symptoms. Every day we delay enforcement is more corrupted records to clean up.


The Standard: E.164

We store all phone numbers in E.164 format.

E.164 is the international telephone numbering standard defined by the ITU. It is the format that all major telephony APIs (Twilio, Vonage, AWS SNS) require natively.

Format Rules

+[Country Code][Area Code][Subscriber Number]
ComponentDescriptionExample
+Mandatory prefix+
Country Code1–3 digits, identifies the country1 (US), 44 (UK), 52 (Mexico)
Area CodeRegional identifier (length varies by country)415
Subscriber NumberLocal number (length varies by country)5550100
Full Example+14155550100

What "Clean String" Means

We store the number as a plain string with no spaces, hyphens, parentheses, or any other formatting characters.

User InputStored Value
(415) 555-0100+14155550100
415-555-0100+14155550100
+1 415 555 0100+14155550100
001 415 555 0100+14155550100
+44 20 7946 0958+442079460958
+52 55 1234 5678+525512345678

Rule: We normalize at write time. We never store raw user input as the canonical phone number.


Official Tooling

Use the approved library for your language. Do not use alternatives without discussing with the platform team first.


TypeScript / JavaScript

Package: awesome-phonenumber

npm install awesome-phonenumber

Why this package:

  • Pure TypeScript with full type definitions — no separate @types needed.
  • Wraps Google's libphonenumber — the same library used by Android, Google Maps, and most major telecom APIs.
  • Lightweight and tree-shakeable.
  • Handles parsing, validation, and formatting in one API.
  • No runtime dependencies.

Do not use google-libphonenumber directly. Do not write custom regex validators. Do not use phone, libphonenumber-js, or other alternatives without discussing with the platform team first.


Go

Package: github.com/nyaruka/phonenumbers/v2

go get github.com/nyaruka/phonenumbers/v2

Why this package:

  • Go port of Google's libphonenumber — same parsing and validation rules as the reference implementation.
  • Actively maintained by Nyaruka, the same team behind RapidPro.
  • Handles parsing, validation, and formatting in one API.
  • No CGo required — pure Go.

Do not write custom regex validators. Do not use other Go phone libraries without discussing with the platform team first.


Database Best Practices

Column Type

Store phone numbers as VARCHAR(20) or equivalent string type in your database.

DecisionRecommendation
TypeVARCHAR(20) or TEXT
Length20 characters — E.164 max is 15 digits + + prefix, 20 gives safe headroom
NullableOnly if phone is optional for the entity
DefaultNo default — NULL is better than an empty string
EncodingPlain ASCII — E.164 numbers only use + and digits

Do not use INTEGER or BIGINT for phone numbers. The + prefix is not numeric. Leading zeros in some country codes are significant. Arithmetic on phone numbers is meaningless and dangerous.

Indexes

Always apply an index on columns used for lookups.

-- Unique index: one user, one phone number
CREATE UNIQUE INDEX idx_users_phone_number ON users (phone_number);

-- Non-unique index: lookup by phone without uniqueness constraint
CREATE INDEX idx_events_phone_number ON events (phone_number);

For partial indexes (e.g., only indexed when not null):

-- PostgreSQL
CREATE UNIQUE INDEX idx_users_phone_number
ON users (phone_number)
WHERE phone_number IS NOT NULL;

Because E.164 is a normalized string, string equality lookups (WHERE phone_number = '+14155550100') are fast and index-friendly. This is the main reason we normalize before storing — mixed formats make indexes useless.

Migration Notes for Existing Dirty Data

If you are migrating an existing column with mixed formats, do not do it inline. Create a migration plan and discuss with the data team. The general approach:

  1. Add a new column phone_number_e164 VARCHAR(20).
  2. Backfill using a migration script that calls toE164 per row.
  3. Validate all rows were successfully converted (log failures).
  4. Rename columns once the backfill is verified.
  5. Apply the index.
  6. Drop the old column in a separate migration.

Never backfill and drop in the same migration. It is not reversible.


Quick Reference

✅ Store as: +14155550100
❌ Never store: (415) 555-0100 | 415-555-0100 | 4155550100 | 1 415 555 0100
TaskHow
Parse user inputparsePhoneNumber(raw, { regionCode: 'US' })
Check validitypn.valid === true
Check if mobile (SMS)pn.typeIsMobile === true
Get E.164 stringpn.number?.e164
Throw on invalidWrap in toE164(), throw BadRequestError
Store in DBVARCHAR(20), index the column
LanguageApproved package
Node.js / TypeScriptawesome-phonenumber
Gogithub.com/nyaruka/phonenumbers/v2

Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.