Skip to content

How to store phone numbers in a database

Read the number once, when it comes in, and store what it is: its E.164 form, a plus and the digits, as text, with a constraint that refuses anything else. Keep the extension, the country and the line type in their own columns, and what the person typed beside them. Every other form is computed from E.164 when you need it.

The column

The table

PostgreSQL

CREATE TABLE contact (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  -- E.164: a plus, then 2 to 15 digits, the first not 0
  phone       varchar(16) NOT NULL CHECK (phone ~ '^\+[1-9][0-9]{1,14}$'),
  phone_ext   varchar(10) CHECK (phone_ext ~ '^[0-9]{1,10}$'),
  phone_region char(2),   -- ISO 3166-1 alpha-2, from parse: 'GB'
  phone_type  varchar(20), -- the line type, from parse: 'mobile'
  phone_input text        -- as the person typed it
);
CREATE UNIQUE INDEX contact_phone_key ON contact (phone);

SQLite

CREATE TABLE contact (
  id          INTEGER PRIMARY KEY,
  -- E.164: a plus, then 2 to 15 digits, the first not 0 (SQLite has no regular
  -- expressions without an extension: GLOB and length do the same check)
  phone       TEXT NOT NULL CHECK (
                length(phone) BETWEEN 3 AND 16
                AND phone GLOB '+[1-9]*'
                AND substr(phone, 2) NOT GLOB '*[^0-9]*'),
  phone_ext   TEXT CHECK (phone_ext GLOB '[0-9]*' AND phone_ext NOT GLOB '*[^0-9]*' AND length(phone_ext) <= 10),
  phone_region TEXT CHECK (length(phone_region) = 2),
  phone_type  TEXT,
  phone_input TEXT
);
CREATE UNIQUE INDEX contact_phone_key ON contact (phone);

MySQL 8.0.16 and later enforce CHECK constraints too; there the condition is written with REGEXP_LIKE.

What it accepts

Inserted one after the other into an empty table, in both databases; each row is accepted or refused the same way in both.

phonephone_extWhat it isInsert
+447400123456—E.164accepted
+1201555012342E.164 with an extension apartaccepted
+447400123456—the same number againrefused
07400123456—national form, no country coderefused
+44 7400 123456—spacesrefused
+04400123456—a country code cannot start with 0refused
+4474001234567890—sixteen digitsrefused
+1—one digitrefused
+1201555019912aan extension with a letterrefused

Writing a number

Normalise in the application, where you know the person's country: the guides' parse reads what they typed in the plans you serve and returns the E.164 form, the region and the line type, or nothing when no plan accepts the number — then there is nothing to store, and the form says so. Pass the values as parameters.

INSERT INTO contact (phone, phone_ext, phone_region, phone_type, phone_input)
VALUES ($1, $2, $3, $4, $5);
-- $1 '+447400123456'  e164 from parse
-- $2 NULL             the extension, if the form has a field for it
-- $3 'GB'             region from parse
-- $4 'mobile'         type from parse
-- $5 '07400 123456'   what the person typed
  • The region settles numbers that share a country code (+1 is 25 plans, +44 is 4) and lets you filter by country without parsing again.
  • The line type is the one the plan gives the range (line types at signup); store it with the date if you act on it, since plans change.
  • The input as typed answers the support question "I entered my number correctly" and lets you parse again after a plan update.
  • Display is computed: the national form for people in the same country, the international form for everyone else.

The SQL above is run before it is published: in SQLite and in PostgreSQL 16, each row inserted and its outcome checked against the table.