A login failure traced to mismatched phone-number storage exposed a flaw. One user’s account was blocked because the database held the same German mobile number in two forms—0171 5550134 in one row and +49 171 5550134 in another—so the system treated them as distinct entries. The result: an OTP never arrived, and a “send code” button became a gamble.
Why phone numbers are tougher than dates
Developers often trust a regex to tame phone-number input. That trust shatters the moment a number crosses a border or a national plan changes. Dates follow a predictable calendar; phone numbers mutate with carriers, regulations, and cultural conventions.
The hidden cost of raw strings
Storing a phone number as a plain string looks unique—until two representations point to the same line. OTP systems that query the database for “the” number end up sending a code to a duplicate that never reaches the user.
The problem worsens when numbers sit in numeric fields. A BIGINT column strips the leading + and any leading zeros, turning +49 171 5550134 into 491715550134. Without the plus sign, reconstructing the original format becomes a guess.
The E.164 contract
The international telephone-numbering plan, E.164, defines a single, portable representation:
- Begins with a
+ - Followed by a 1- to 3-digit country code
- Then the subscriber number
- No more than 15 digits total
- No spaces, dots, or dashes
E.164 does not guarantee the number is active; it only guarantees the string follows a valid structural pattern. Treat it as a format contract, not a reachability oracle.
Common pitfalls that regex can’t fix
- Numeric storage – BIGINT discards the
+and leading zeros. Use a text column (TEXT or VARCHAR) instead. - Hard-coded regexes – National plans change. Mexico dropped its trunk prefix in 2019; Argentina now requires a
9after the country code for mobile lines. A static pattern quickly becomes obsolete. - Blind zero stripping – Italian landlines keep a leading zero, German ones do not. A blanket “remove leading zeros” rule corrupts Italian data while leaving German numbers untouched.
- Assuming format equals deliverability – libphonenumber validates structure but cannot tell whether the handset is on or the number is ported.
Building a reliable pipeline
- Ask for a country – Add a country selector on sign-up forms and pass that region to the parser.
- Show live formatting – Use “AsYouType” formatting so users see the correct pattern as they type.
- Validate on blur – Run validation after the user leaves the field rather than on each keystroke; this reduces friction.
- Persist only the E.164 string – Store the normalized
+-prefixed number in the database. - Format at the edge – Convert back to a human-friendly layout only in the UI or email templates.
When migrating legacy data, keep rows that fail validation. Parse each existing entry into a new column, flag the failures, and generate a report. Silent drops create support tickets that later turn into costly fixes.
A quick checklist for developers
- Store numbers as TEXT/VARCHAR in E.164 format.
- Use Google’s libphonenumber library; it handles the messy reality of global plans.
- Provide a default region for users who omit the country code.
- Call
is_valid_numberfor live sign-ups; it checks that the number fits regional rules. - Use
is_possible_numberwhen cleaning bulk data; it catches obviously malformed entries without rejecting borderline cases.
Proper phone-number handling is not a nice-to-have; it is a prerequisite for any system that relies on reliable user contact. By normalizing to E.164 and delegating parsing to a battle-tested library, developers eliminate a class of bugs that silently erode trust and revenue. Treat phone numbers as structured data, not free-form text, and let the standards do the heavy lifting.
