How to Match Phone Numbers
with Regex
Patterns that actually work — E.164, US, UK, and international formats, with common pitfalls and normalisation one-liners.
How to match phone numbers with regex: no single pattern covers every country, so the right approach is to pick the strictest pattern your use case allows — E.164 for storage and APIs, a locale-specific pattern for form validation, and a loose permissive pattern only when extracting from unstructured text.
Why Phone Number Regex Is Hard
Phone numbers are deceptively tricky to match with regex because there is no universal format. The same physical number — say, a London mobile — can be written at least five different ways, all of them valid:
# Five valid representations of the same UK mobile number
+44 7911 123456 # E.164-ish with spaces
+447911123456 # Strict E.164 (no spaces)
07911 123456 # UK local format (leading 0, no country code)
07911-123-456 # Dashes as separators
+44 (0)7911 123456 # Country code + parenthesised trunk prefix
On top of format variation, several structural properties make regex harder:
The practical takeaway: use the narrowest pattern that your data actually requires. A strict E.164 pattern is far easier to reason about than a pattern trying to handle every world format simultaneously.
E.164 Format — The Safe Choice
E.164 is the ITU standard for international phone numbers: a leading +, then 1–3 digit country code, then the subscriber number, with no spaces or separators — 8 to 15 digits total after the +. This is what Twilio, Stripe, and most telephony APIs expect.
^\+[1-9]\d{7,14}$
^
\+
[1-9]
\d{7,14}
$
// JavaScript
const E164 = /^\+[1-9]\d{7,14}$/;
E164.test('+12025550123') // ✓ US number
E164.test('+447911123456') // ✓ UK mobile
E164.test('+85291234567') // ✓ Hong Kong
E164.test('+1 202 555 0123') // ✗ spaces not allowed
E164.test('12025550123') // ✗ missing leading +
E164.test('+0123456789') // ✗ country code starts with 0
# Python
import re
E164 = re.compile(r'^\+[1-9]\d{7,14}$')
def is_e164(phone: str) -> bool:
return bool(E164.match(phone))
# Use for: API payloads, database storage, Twilio/SMS sends
# Reject at the form level and prompt user to include country code
When to use E.164: any time you control the input — API payloads, database storage, message sending. Reject non-E.164 at the form level and show a helper prompt ("Include country code, e.g. +1 202 555 0123").
US and Canada Pattern
NANP numbers (North American Numbering Plan) cover the US, Canada, and several Caribbean countries. The format is (NXX) NXX-XXXX where N is 2–9. A permissive but correct pattern:
^(\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$
(\+1[-.\s]?)?
\(?
\d{3}
\)?
[-.\s]?
\d{3}
[-.\s]?
\d{4}$
Test cases — these should all pass:
| Input | Result | Notes |
|---|---|---|
| 2025550123 | ✓ pass | No separators, no country code |
| 202-555-0123 | ✓ pass | Dash separators |
| 202.555.0123 | ✓ pass | Dot separators |
| (202) 555-0123 | ✓ pass | Parens + space + dash |
| +1 202 555 0123 | ✓ pass | E.164-style with spaces |
| +1-202-555-0123 | ✓ pass | E.164-style with dashes |
| 555-0123 | ✗ fail | Missing area code (7 digits only) |
| 1-800-555-01234 | ✗ fail | Too many digits |
| (202 555-0123 | ✗ fail | Unmatched open paren — actually passes! See note below. |
Paren pairing caveat: because \(? and \)? are independently optional, (202 555-0123 (open paren, no close) passes. If this matters, use a conditional group: (\(?\d{3}\)?|\(\d{3}\)) — or just normalise input by stripping all non-digits before validating.
// JavaScript
const US_PHONE = /^(\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
# Python
US_PHONE = re.compile(r'^(\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$')
UK Pattern
UK numbers are more complex than US numbers. Area codes have variable length — London uses a 2-digit area code (020) with 8-digit local numbers, while many rural areas use 5-digit area codes with 6-digit local numbers. The total digit count is always 10 (excluding the leading trunk prefix 0 or country code +44).
^(\+44\s?|0)(\d\s?){10}$
Breaking it down:
(\+44\s?|0)
(\d\s?){10}
$
// JavaScript
const UK_PHONE = /^(\+44\s?|0)(\d\s?){10}$/;
UK_PHONE.test('07911123456') // ✓ mobile, no spaces
UK_PHONE.test('07911 123456') // ✓ mobile, one space
UK_PHONE.test('020 7946 0958') // ✓ London landline
UK_PHONE.test('+44 7911 123456') // ✓ international prefix
UK_PHONE.test('+447911123456') // ✓ strict E.164
UK_PHONE.test('7911123456') // ✗ missing prefix (0 or +44)
UK_PHONE.test('+44 (0)7911 123456') // ✗ parenthesised trunk prefix not matched
The +44 (0)7911 123456 style with a parenthesised trunk prefix is technically incorrect (the 0 is redundant when dialling internationally) but appears in print and on business cards. If you need to handle it, add a third alternative to the prefix group:
^(\+44\s?(\(0\)\s?)?|0)(\d\s?){10}$
Loose International Validator
When you need to accept phone numbers from any country without maintaining country-specific patterns, use a permissive pattern that rejects clearly non-phone strings while accepting the full range of international formats:
^\+?[\d\s\-().]{7,20}$
\+?
[\d\s\-().]{7,20}
$
When to use this vs strict patterns: use the loose pattern only as a pre-filter when you cannot know the user's country. Always follow it with a digit-count check after stripping non-digits (must be 7–15 digits). Never use it as your only validation layer — it will pass ZIP codes and other short digit strings if you forget the minimum-length constraint.
# Python: two-stage validation
LOOSE = re.compile(r'^\+?[\d\s\-().]{7,20}$')
def is_valid_international(phone: str) -> bool:
if not LOOSE.match(phone.strip()):
return False
digits = re.sub(r'\D', '', phone)
return 7 <= len(digits) <= 15 # E.164 max
Validation vs Extraction
There is a fundamental difference between validating a phone number field (the entire string must be a number) and extracting phone numbers from free text (find all numbers inside a paragraph). The patterns and anchoring requirements differ completely.
- Always use
^and$anchors - Strict pattern — reject ambiguous input
- Test with
.test()/re.match() - Return true/false
- No anchors — or use word boundaries
\b - Permissive pattern — catch varied formats
- Use
matchAll()/re.findall() - Post-process results to normalise
Here is how to extract all phone numbers from a paragraph of text:
// JavaScript — extract all phone numbers from free text
const text = `Call us on +1 (202) 555-0123 or reach support at
+44 7911 123456. Emergency line: 0800-111-4567.`;
// Pattern: must start and end with a digit, allows separators in between
const EXTRACT = /\+?[0-9][0-9\s\-().]{5,18}[0-9]/g;
const found = [...text.matchAll(EXTRACT)].map(m => m[0].trim());
// → ['+1 (202) 555-0123', '+44 7911 123456', '0800-111-4567']
# Python — extract all phone numbers from free text
import re
text = """Call us on +1 (202) 555-0123 or reach support at
+44 7911 123456. Emergency line: 0800-111-4567."""
EXTRACT = re.compile(r'\+?[0-9][0-9\s\-().]{5,18}[0-9]')
found = [m.group().strip() for m in EXTRACT.finditer(text)]
# → ['+1 (202) 555-0123', '+44 7911 123456', '0800-111-4567']
Common Mistakes
Without ^ and $, the pattern matches anywhere inside the input — a 200-character garbage string that happens to contain 10 consecutive digits will pass.
// Wrong — no anchors
const BAD = /\d{10}/;
BAD.test('not a phone: 1234567890 (found!)'); // true — should be false
// Correct — anchored
const GOOD = /^\d{10}$/;
GOOD.test('not a phone: 1234567890 (found!)'); // false
Outside a character class, + is a quantifier meaning "one or more". You must escape it as \+ to match a literal plus sign. Inside a character class [+], the backslash is not required but does no harm.
// Wrong — unescaped + is a quantifier, not a literal
const BAD = /^+\d{10}/; // SyntaxError: nothing to repeat
// Correct
const GOOD = /^\+\d{10}/; // matches +1234567890
Business numbers often include an extension: +1 202 555 0123 ext 456 or +1-202-555-0123 x456. A pattern without an optional extension group will reject these valid numbers.
# Add an optional extension group
US_WITH_EXT = re.compile(
r'^(\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}'
r'(\s*(ext|x|#)\.?\s*\d{1,6})?$',
re.IGNORECASE
)
Any pattern that allows 5 consecutive digits without a minimum total length will match US ZIP codes. Always enforce a digit count of at least 7 after stripping non-digits.
// Wrong — matches ZIP codes
const BAD = /\+?[\d\s\-]{5,}/;
BAD.test('90210'); // true — that's a ZIP code
// Better — require 7+ digits after stripping
const hasEnoughDigits = s => (s.replace(/\D/g, '').length >= 7);
hasEnoughDigits('90210'); // false
hasEnoughDigits('555-0123'); // true (7 digits)
Normalisation After Matching
Once you have a match, the next step is almost always normalisation — strip all non-digit characters, then reformat to E.164 for consistent storage. These one-liners handle the most common case (US numbers without a country code):
# Python — normalise to E.164
import re
def to_e164(phone: str, default_country: str = '1') -> str:
digits = re.sub(r'\D', '', phone) # strip everything non-digit
if len(digits) == 10: # bare US/Canada number
digits = default_country + digits
if not (8 <= len(digits) <= 15):
raise ValueError(f"Unexpected digit count: {len(digits)}")
return f'+{digits}'
to_e164('(202) 555-0123') # → '+12025550123'
to_e164('+44 7911 123456') # → '+447911123456'
to_e164('07911 123456', '44') # → '+447911123456' (UK local)
// JavaScript — normalise to E.164
function toE164(phone, defaultCountry = '1') {
const digits = phone.replace(/\D/g, '');
const normalised = digits.length === 10
? defaultCountry + digits
: digits;
if (normalised.length < 8 || normalised.length > 15)
throw new Error(`Unexpected digit count: ${normalised.length}`);
return `+${normalised}`;
}
toE164('(202) 555-0123') // → '+12025550123'
toE164('+44 7911 123456') // → '+447911123456'
The normalisation step makes downstream processing much simpler — deduplication, database lookups, and SMS sending all become trivial when every stored number is in the same canonical format.
Test Your Phone Number Pattern
Paste any pattern from this guide and your test numbers into the free regex tester — runs entirely in your browser, nothing is uploaded.
Open Regex Tester →