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
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]
| Component | Description | Example |
|---|---|---|
+ | Mandatory prefix | + |
| Country Code | 1–3 digits, identifies the country | 1 (US), 44 (UK), 52 (Mexico) |
| Area Code | Regional identifier (length varies by country) | 415 |
| Subscriber Number | Local 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 Input | Stored 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
@typesneeded. - 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.
| Decision | Recommendation |
|---|---|
| Type | VARCHAR(20) or TEXT |
| Length | 20 characters — E.164 max is 15 digits + + prefix, 20 gives safe headroom |
| Nullable | Only if phone is optional for the entity |
| Default | No default — NULL is better than an empty string |
| Encoding | Plain ASCII — E.164 numbers only use + and digits |
Do not use
INTEGERorBIGINTfor 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:
- Add a new column
phone_number_e164 VARCHAR(20). - Backfill using a migration script that calls
toE164per row. - Validate all rows were successfully converted (log failures).
- Rename columns once the backfill is verified.
- Apply the index.
- 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
| Task | How |
|---|---|
| Parse user input | parsePhoneNumber(raw, { regionCode: 'US' }) |
| Check validity | pn.valid === true |
| Check if mobile (SMS) | pn.typeIsMobile === true |
| Get E.164 string | pn.number?.e164 |
| Throw on invalid | Wrap in toE164(), throw BadRequestError |
| Store in DB | VARCHAR(20), index the column |
| Language | Approved package |
|---|---|
| Node.js / TypeScript | awesome-phonenumber |
| Go | github.com/nyaruka/phonenumbers/v2 |
Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.