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 9 after 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

  1. Ask for a country – Add a country selector on sign-up forms and pass that region to the parser.
  2. Show live formatting – Use “AsYouType” formatting so users see the correct pattern as they type.
  3. Validate on blur – Run validation after the user leaves the field rather than on each keystroke; this reduces friction.
  4. Persist only the E.164 string – Store the normalized +-prefixed number in the database.
  5. 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_number for live sign-ups; it checks that the number fits regional rules.
  • Use is_possible_number when 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.