Validate phone numbers in Java
One file that reads a number the way people type it, checks it against the numbering plans you pick, names its line type, writes it as E.164 and as a tel: link, and says what to dial from another country. The patterns are the plans' own. The tests come with it, and the code on this page was run before it was published.
- Files
Phone.java, tests inPhoneTest.java- Needs
- Java 17 or later; standard library only
- Tests
- JUnit 5
- Plans as of
- 2026-10-25
What it answers
| Call | With the United Kingdom's plan loaded |
|---|---|
parse “07400 123456”, default region GB | +447400123456, GB, mobile |
toTel | tel:+447400123456 |
dialFrom from US | 011447400123456 |
parse “07400 12345” | Optional.empty(): no range of the plan has that length |
The plans in this file
Add the countries whose numbers you accept; the file and its tests below are generated again for them, in your browser.
- United Kingdom +44
- United States +1
- France +33
1. The plans
PLANS holds one entry per plan: its region (the ISO code), the country code, the trunk prefix dialled before a national number, the exit code dialled before an international one, and a pattern per line type. The patterns are the plans' own, anchored, in the order the file tries them: the special ranges first (premium rate, toll-free, shared cost, VoIP, personal, pager, UAN, voicemail), then fixed line, then mobile. A plan that names no single exit code gets an empty one, and calls out of it use the + form.
// Phone.java: validate, normalise and dial phone numbers with the national numbering plans.
// Plans: GB +44, US +1, FR +33 (data as of 2026-10-25).
// Other plans: https://phonelookup.dev/guides/validate-phone-numbers-java?plans=gb,us,fr
// Each pattern matches a national significant number: digits only, no country code, no trunk prefix.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
public final class Phone {
/** One numbering plan: region, country code, trunk prefix, exit code and a pattern per line type. */
public record Plan(String region, String cc, String trunk, String exit, List<Map.Entry<String, Pattern>> types) {}
/** A number as read: E.164, the plan it belongs to, its line type, country code and national number. */
public record PhoneNumber(String e164, String region, String type, String cc, String nsn) {}
private static Map.Entry<String, Pattern> type(String name, String regex) {
return Map.entry(name, Pattern.compile(regex));
}
public static final List<Plan> PLANS = List.of(
new Plan("GB", "44", "0", "00", List.of(
type("premiumRate", "(?:8(?:4[2-5]|7[0-3])|9(?:[01]\\d|8[2-49]))\\d{7}|845464\\d"),
type("tollFree", "80[08]\\d{7}|800\\d{6}|8001111"),
type("voip", "56\\d{8}"),
type("personalNumber", "70\\d{8}"),
type("pager", "76(?:464|652)\\d{5}|76(?:0[0-28]|2[356]|34|4[01347]|5[49]|6[0-369]|77|8[14]|9[139])\\d{6}"),
type("uan", "(?:3[0347]|55)\\d{8}"),
type("fixedLine", "(?:1(?:1(?:3(?:[0-58]\\d\\d|73[0-5])|4(?:(?:[0-5]\\d|70)\\d|69[7-9])|(?:(?:5[0-26-9]|[78][0-49])\\d|6(?:[0-4]\\d|5[01]))\\d)|(?:2(?:(?:0[024-9]|2[3-9]|3[3-79]|4[1-689]|[58][02-9]|6[0-47-9]|7[013-9]|9\\d)\\d|1(?:[0-7]\\d|8[0-3]))|(?:3(?:0\\d|1[0-8]|[25][02-9]|3[02-579]|[468][0-46-9]|7[1-35-79]|9[2-578])|4(?:0[03-9]|[137]\\d|[28][02-57-9]|4[02-69]|5[0-8]|[69][0-79])|5(?:0[1-35-9]|[16]\\d|2[024-9]|3[015689]|4[02-9]|5[03-9]|7[0-35-9]|8[0-468]|9[0-57-9])|6(?:0[034689]|1\\d|2[0-35689]|[38][013-9]|4[1-467]|5[0-69]|6[13-9]|7[0-8]|9[0-24578])|7(?:0[0246-9]|2\\d|3[0236-8]|4[03-9]|5[0-46-9]|6[013-9]|7[0-35-9]|8[024-9]|9[02-9])|8(?:0[35-9]|2[1-57-9]|3[02-578]|4[0-578]|5[124-9]|6[2-69]|7\\d|8[02-9]|9[02569])|9(?:0[02-589]|[18]\\d|2[02-689]|3[1-57-9]|4[2-9]|5[0-579]|6[2-47-9]|7[0-24578]|9[2-57]))\\d)\\d)|2(?:0[013478]|3[0189]|4[017]|8[0-46-9]|9[0-2])\\d{3})\\d{4}|1(?:2(?:0(?:46[1-4]|87[2-9])|545[1-79]|76(?:2\\d|3[1-8]|6[1-6])|9(?:7(?:2[0-4]|3[2-5])|8(?:2[2-8]|7[0-47-9]|8[3-5])))|3(?:6(?:38[2-5]|47[23])|8(?:47[04-9]|64[0157-9]))|4(?:044[1-7]|20(?:2[23]|8\\d)|6(?:0(?:30|5[2-57]|6[1-8]|7[2-8])|140)|8(?:052|87[1-3]))|5(?:2(?:4(?:3[2-79]|6\\d)|76\\d)|6(?:26[06-9]|686))|6(?:06(?:4\\d|7[4-79])|295[5-7]|35[34]\\d|47(?:24|61)|59(?:5[08]|6[67]|74)|9(?:55[0-4]|77[23]))|7(?:26(?:6[13-9]|7[0-7])|(?:442|688)\\d|50(?:2[0-3]|[3-68]2|76))|8(?:27[56]\\d|37(?:5[2-5]|8[239])|843[2-58])|9(?:0(?:0(?:6[1-8]|85)|52\\d)|3583|4(?:66[1-8]|9(?:2[01]|81))|63(?:23|3[1-4])|9561))\\d{3}"),
type("mobile", "7(?:457[0-57-9]|700[01]|911[028])\\d{5}|7(?:[1-3]\\d\\d|4(?:[0-46-9]\\d|5[0-689])|5(?:0[0-8]|[13-9]\\d|2[0-35-9])|7(?:0[1-9]|[1-7]\\d|8[02-9]|9[0-689])|8(?:[014-9]\\d|[23][0-8])|9(?:[024-9]\\d|1[02-9]|3[0-689]))\\d{6}")
)),
new Plan("US", "1", "1", "011", List.of(
type("premiumRate", "900[2-9]\\d{6}"),
type("tollFree", "8(?:00|33|44|55|66|77|88)[2-9]\\d{6}"),
type("personalNumber", "52(?:3(?:[2-46-9][02-9]\\d|5(?:[02-46-9]\\d|5[0-46-9]))|4(?:[2-478][02-9]\\d|5(?:[034]\\d|2[024-9]|5[0-46-9])|6(?:0[1-9]|[2-9]\\d)|9(?:[05-9]\\d|2[0-5]|49)))\\d{4}|52[34][2-9]1[02-9]\\d{4}|5(?:00|2[125-9]|3[23]|44|66|77|88)[2-9]\\d{6}"),
type("fixedLine", "983[2-57-9]\\d{6}|(?:2(?:0[1-35-9]|1[02-9]|2[03-57-9]|3[1459]|4[08]|5[1-46]|6[0279]|7[02469]|8[13])|3(?:0[1-57-9]|1[02-9]|2[013-79]|3[0-24679]|4[167]|5[0-3]|6[01349]|8[056])|4(?:0[124-9]|1[02-579]|2[3-5]|3[0245]|4[023578]|58|6[349]|7[02589]|8[04])|5(?:0[1-57-9]|1[0235-8]|20|3[0149]|4[01]|5[179]|6[1-47]|7[0-5]|8[0256])|6(?:0[1-35-9]|1[024-9]|2[03689]|3[016]|4[0156]|5[01679]|6[0-279]|78|8[0-269])|7(?:0[1-46-8]|1[2-9]|2[04-8]|3[0-2478]|4[0378]|5[47]|6[02359]|7[0-59]|8[156])|8(?:0[1-68]|1[02-8]|2[0168]|3[0-2589]|4[03578]|5[046-9]|6[02-5]|7[028])|9(?:0[1346-9]|1[02-9]|2[0589]|3[0146-8]|4[01357-9]|5[12469]|7[0-3589]|8[04-69]))[2-9]\\d{6}"),
type("mobile", "983[2-57-9]\\d{6}|(?:2(?:0[1-35-9]|1[02-9]|2[03-57-9]|3[1459]|4[08]|5[1-46]|6[0279]|7[02469]|8[13])|3(?:0[1-57-9]|1[02-9]|2[013-79]|3[0-24679]|4[167]|5[0-3]|6[01349]|8[056])|4(?:0[124-9]|1[02-579]|2[3-5]|3[0245]|4[023578]|58|6[349]|7[02589]|8[04])|5(?:0[1-57-9]|1[0235-8]|20|3[0149]|4[01]|5[179]|6[1-47]|7[0-5]|8[0256])|6(?:0[1-35-9]|1[024-9]|2[03689]|3[016]|4[0156]|5[01679]|6[0-279]|78|8[0-269])|7(?:0[1-46-8]|1[2-9]|2[04-8]|3[0-2478]|4[0378]|5[47]|6[02359]|7[0-59]|8[156])|8(?:0[1-68]|1[02-8]|2[0168]|3[0-2589]|4[03578]|5[046-9]|6[02-5]|7[028])|9(?:0[1346-9]|1[02-9]|2[0589]|3[0146-8]|4[01357-9]|5[12469]|7[0-3589]|8[04-69]))[2-9]\\d{6}")
)),
new Plan("FR", "33", "0", "00", List.of(
type("premiumRate", "836(?:0[0-36-9]|[1-9]\\d)\\d{4}|8(?:1[2-9]|2[2-47-9]|3[0-57-9]|[569]\\d|8[0-35-9])\\d{6}"),
type("tollFree", "80[0-5]\\d{6}"),
type("sharedCost", "8(?:1[01]|2[0156]|4[024]|84)\\d{6}"),
type("voip", "9\\d{8}"),
type("uan", "80[6-9]\\d{6}"),
type("fixedLine", "(?:26[013-9]|59[1-35-9])\\d{6}|(?:[13]\\d|2[0-57-9]|4[1-9]|5[0-8])\\d{7}"),
type("mobile", "(?:6(?:[0-24-8]\\d|3[0-8]|9[589])|7[3-9]\\d)\\d{6}")
))
);
private Phone() {}2. Reading a number
parse keeps the digits and a leading plus. With the plus, or with the default region's exit code in front (00 from the United Kingdom, 011 from the United States), the first one to three digits are the country code: under ITU-T E.164 no code is the prefix of another, so at most one length names a plan, and the plans sharing a code (+1, +7, +44) are tried in turn. Without either, the number is national and is read in the default region's plan. The trunk prefix is tried stripped first, so “+44 (0)7400 123456” reads too. A number in both the fixed-line and the mobile ranges comes back as fixedLineOrMobile, which is every ordinary number in the +1 plans. Digits typed in other scripts are dropped with the punctuation; convert them first if your users type them. Anything no plan accepts gives Optional.empty().
/**
* Reads a number the way people type it: "+44 7400 123456", "07400 123456", "00 44 7400 123456".
* A number without + or an exit code is read in the plan of defaultRegion (an ISO code such as "GB", or null).
* Empty when no plan in PLANS accepts it.
*/
public static Optional<PhoneNumber> parse(String text, String defaultRegion) {
String s = text.replaceAll("[^0-9+]", "");
String digits = s.replace("+", "");
if (digits.length() < 3 || digits.length() > 17) return Optional.empty();
if (s.startsWith("+")) return international(digits);
Plan home = plan(defaultRegion);
if (home == null) return Optional.empty();
if (!home.exit().isEmpty() && digits.startsWith(home.exit())) {
return international(digits.substring(home.exit().length()));
}
List<Plan> plans = new ArrayList<>(List.of(home));
PLANS.stream().filter(p -> p.cc().equals(home.cc()) && p != home).forEach(plans::add);
return match(plans, digits);
}
private static Plan plan(String region) {
return PLANS.stream().filter(p -> p.region().equals(region)).findFirst().orElse(null);
}
// Country codes are prefix-free (ITU-T E.164): the first one to three digits that name a plan are the code.
private static Optional<PhoneNumber> international(String digits) {
for (int size = 1; size <= Math.min(3, digits.length()); size++) {
String cc = digits.substring(0, size);
List<Plan> plans = PLANS.stream().filter(p -> p.cc().equals(cc)).toList();
if (!plans.isEmpty()) return match(plans, digits.substring(size));
}
return Optional.empty();
}
// The trunk prefix is tried stripped first: "+44 (0)7400 123456" is common.
private static Optional<PhoneNumber> match(List<Plan> plans, String nsn) {
for (Plan plan : plans) {
List<String> tries = !plan.trunk().isEmpty() && nsn.startsWith(plan.trunk())
? List.of(nsn.substring(plan.trunk().length()), nsn)
: List.of(nsn);
for (String national : tries) {
String type = typeOf(plan, national);
if (type != null) {
return Optional.of(new PhoneNumber("+" + plan.cc() + national, plan.region(), type, plan.cc(), national));
}
}
}
return Optional.empty();
}
// The first matching type wins; a number in both the fixed-line and the mobile ranges is reported as such.
private static String typeOf(Plan plan, String nsn) {
List<String> hits = plan.types().stream()
.filter(t -> t.getValue().matcher(nsn).matches())
.map(Map.Entry::getKey)
.toList();
if (hits.isEmpty()) return null;
return hits.get(0).equals("fixedLine") && hits.contains("mobile") ? "fixedLineOrMobile" : hits.get(0);
}3. Writing and dialling it
Store the E.164 form, a plus and digits only: one string per number, and every other format follows from it.toTel writes the tel: URI for links (RFC 3966). dialFrom gives the digits a person dials from another country: the caller's exit code, the country code and the national number, or, between plans that share a country code, a national call with the trunk prefix. The dialling helper explains each case.
/** The tel: URI (RFC 3966), for links. */
public static String toTel(PhoneNumber n) {
return "tel:" + n.e164();
}
/**
* What to dial from a phone in fromRegion. Inside one country code (the UK and Jersey, the US and Canada)
* the call is dialled like a national one, with the trunk prefix; otherwise the exit code, the country code
* and the national number. The + form when fromRegion is not in PLANS or its plan names no exit code.
*/
public static String dialFrom(PhoneNumber n, String fromRegion) {
Plan from = plan(fromRegion);
if (from == null) return n.e164();
if (from.cc().equals(n.cc())) return from.trunk() + n.nsn();
return from.exit().isEmpty() ? n.e164() : from.exit() + n.cc() + n.nsn();
}
}The tests
The cases are the plans' own example numbers, one per line type in international form; the first plan's mobile number again in national form, with “(0)”, and dialled with an exit code; then numbers no plan accepts, and three calls dialled. Their expected values are computed when the page is built, by the same logic in TypeScript. The generator behind this page is checked by running its output in JUnit 5, for these plans and for all 245 plans at once.
# Maven: Phone.java in src/main/java, PhoneTest.java in src/test/java,
# org.junit.jupiter:junit-jupiter as a test dependency
mvn testimport static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
class PhoneTest {
record Case(String text, String region, String e164, String type) {}
private static Phone.PhoneNumber read(String text) {
return Phone.parse(text, null).orElseThrow(() -> new AssertionError(text));
}
@Test
void readsEachLineTypeOfEachPlan() {
List<Case> cases = List.of(
new Case("+44 7400 123456", null, "+447400123456", "mobile"),
new Case("+44 121 234 5678", null, "+441212345678", "fixedLine"),
new Case("+44 800 123 4567", null, "+448001234567", "tollFree"),
new Case("+44 56 1234 5678", null, "+445612345678", "voip"),
new Case("+44 901 234 5678", null, "+449012345678", "premiumRate"),
new Case("+44 70 1234 5678", null, "+447012345678", "personalNumber"),
new Case("+44 76 4012 3456", null, "+447640123456", "pager"),
new Case("+44 55 1234 5678", null, "+445512345678", "uan"),
new Case("+1 201-555-0123", null, "+12015550123", "fixedLineOrMobile"),
new Case("+1 800-234-5678", null, "+18002345678", "tollFree"),
new Case("+1 900-234-5678", null, "+19002345678", "premiumRate"),
new Case("+1 500-234-5678", null, "+15002345678", "personalNumber"),
new Case("+33 6 12 34 56 78", null, "+33612345678", "mobile"),
new Case("+33 1 23 45 67 89", null, "+33123456789", "fixedLine"),
new Case("+33 801 23 45 67", null, "+33801234567", "tollFree"),
new Case("+33 9 12 34 56 78", null, "+33912345678", "voip"),
new Case("+33 884 01 23 45", null, "+33884012345", "sharedCost"),
new Case("+33 891 12 34 56", null, "+33891123456", "premiumRate"),
new Case("+33 806 12 34 56", null, "+33806123456", "uan"),
new Case("07400 123456", "GB", "+447400123456", "mobile"),
new Case("+44 (0)7400 123456", null, "+447400123456", "mobile"),
new Case("00 1 201-555-0123", "GB", "+12015550123", "fixedLineOrMobile")
);
for (Case c : cases) {
Phone.PhoneNumber n = Phone.parse(c.text(), c.region()).orElseThrow(() -> new AssertionError(c.text()));
assertEquals(c.e164(), n.e164(), c.text());
assertEquals(c.type(), n.type(), c.text());
}
}
@Test
void rejectsWhatNoPlanAccepts() {
assertTrue(Phone.parse("12", "GB").isEmpty());
assertTrue(Phone.parse("+44 7400 12345", null).isEmpty());
assertTrue(Phone.parse("+49 1512 3456789", null).isEmpty());
assertTrue(Phone.parse("07400 123456", null).isEmpty());
}
@Test
void writesAndDials() {
assertEquals("tel:+447400123456", Phone.toTel(read("+44 7400 123456")));
assertEquals("0012015550123", Phone.dialFrom(read("+1 201-555-0123"), "GB"));
assertEquals("011447400123456", Phone.dialFrom(read("+44 7400 123456"), "US"));
assertEquals("07400123456", Phone.dialFrom(read("+44 7400 123456"), "GB"));
}
}
What is specific to Java
matcher(nsn).matches()requires the whole string to match;find()would accept a number with digits before or after the pattern.Patternis immutable and thread-safe,Matcheris not: the patterns are compiled once into static fields, and each call makes its own matcher.- In a string literal every backslash of a pattern is doubled (
\\d); text blocks process escapes too, so they do not help. \dmeans[0-9]unless the pattern is compiled withUNICODE_CHARACTER_CLASS.
Sources of these plans: National Telephone Numbering Plan (Ofcom); National numbering plans, ITU-T E.164 (ITU); North American Numbering Plan Administrator (NANPA); Canadian Numbering Administrator (CNAC); Plan national de numérotation (ARCEP)