Regular expressions in Java — theory with email, phone and Spanish ID validation (and the error that made me think Java was broken)
My first regular expression in Java was "\d+". Three characters. I knew exactly what I meant: “one or more digits”. And Java answered with an angry wall of red:
error: illegal escape character
I assumed I’d mistyped something so short it couldn’t possibly be wrong. I hadn’t. The catch is that a regex in Java lives inside a String, and Strings have their own rules for the backslash. That, plus a few other ideas, is what makes regexes look like black magic when they’re really a small, very logical language.
In this article you’ll learn to read them, write them and use them in Java with matches, Pattern, Matcher and groups, through one self-explanatory example: validating an email, a phone number and a Spanish ID (DNI).
Table of Contents
the phone and ID formats are Spanish because that’s the context of this blog. The tools are identical for any other format; only the pattern changes.
What is a regular expression?
A regular expression (regex) is a pattern that describes a set of texts. Instead of asking “is this text equal to 612345678?”, you ask “does this text have the shape of a phone number?”.
They’re used for three main things:
- Validate: does this text follow the format? (email, ID, password…)
- Search and extract: find dates, numbers or emails inside a long text.
- Replace or split: clean up spaces, change formats, split on several separators.
You’ve already used regexes without calling them that: split("\\s+") and split("\\.") in the Strings article were regular expressions. This article explains where those backslashes come from.
How to write a regex in Java: the double backslash
In a regex, \d means “a digit”. But in Java the pattern is written inside a String, and in a String the backslash already has a meaning of its own (\n, \t, \"). Java sees \d, doesn’t recognize it as a String escape and complains with illegal escape character.
The fix: double the backslash. \\ in the String becomes a single \ in the pattern.
"12345".matches("\\d+"); // ✅ the real pattern the regex engine sees is \d+
"12345".matches("\d+"); // ❌ won't compile: illegal escape character
Writing \d, \s or \. with a single backslash. Simple rule: every backslash in the regex is written twice in Java.
The basic building blocks
Literal characters
A normal character stands for itself: "cat".matches("cat") is true.
Character classes: one character out of several
| Pattern | Means | Example that matches |
|---|---|---|
[abc] | a, b or c | b |
[a-z] | any lowercase letter | q |
[A-Za-z] | any letter (no accents or ñ) | Q |
[0-9] | a digit | 7 |
[^0-9] | anything but a digit | x |
Shortcuts
| Shortcut | Means | In Java |
|---|---|---|
\d | a digit | "\\d" |
\w | letter, digit or _ | "\\w" |
\s | a space, tab or line break | "\\s" |
. | any character (except line break) | "." |
Quantifiers: how many times
| Quantifier | Means |
|---|---|
? | 0 or 1 time |
* | 0 or more times |
+ | 1 or more times |
{n} | exactly n times |
{n,m} | between n and m times |
{n,} | n or more times |
A quantifier applies to the element right before it: \\d+ is “one or more digits”, and ab+ is an a followed by one or more bs (not “one or more ab“).
Anchors and alternatives
| Symbol | Means |
|---|---|
^ | start of the text |
$ | end of the text |
| | “or” (cat|dog) |
( ) | group |
"cat".matches("cat|dog"); // true
"dog".matches("cat|dog"); // true
"catdog".matches("cat|dog"); // false: it's one OR the other, not both
"color".matches("colou?r"); // true: the u is optional
"colour".matches("colou?r"); // true
Escaping: when a symbol must be literal
The characters . * + ? ( ) [ ] { } ^ $ | have special meaning. If you want the actual symbol, escape it with \, which means \\ in Java.
"axb".matches("a.b"); // true → the dot means "any character"
"axb".matches("a\\.b"); // false → now it demands a real dot
"a.b".matches("a\\.b"); // true
"1+1".matches("1\\+1"); // true
If the literal text is long and full of symbols, Pattern.quote("1+1") escapes it for you.
Forgetting to escape the dot. "axb".matches("a.b") gives true and you don’t notice until someone slips in an axb where you expected a.b. (It’s the same reason "2.4.1".split(".") returned an empty array.)
First tool: matches
String.matches(regex) returns true only if the entire text matches the pattern, start to finish.
"12345".matches("\\d+"); // true
"12a45".matches("\\d+"); // false: the 'a' breaks the pattern
"12a45".matches("\\d"); // false: \d is ONE digit, not five
Notice you don’t need ^ or $: matches already requires the pattern to cover the whole text. That makes it perfect for validating and not very useful for searching inside a long text:
"abc".matches("b"); // false: "b" is not ALL the text
"abc".matches(".*b.*"); // true: anything, a b, anything
Validating a phone, an ID and an email
Step by step. Each pattern, explained piece by piece.
Spanish phone number (9 digits, starts with 6, 7, 8 or 9)
String phone = "[6-9]\\d{8}";
// ^^^^^ ^^^^^^
// | └─ 8 more digits
// └─ first digit: 6, 7, 8 or 9
| Text | Matches? | Why |
|---|---|---|
612345678 | ✅ | 6 + 8 digits |
512345678 | ❌ | starts with 5 |
61234567 | ❌ | only 8 digits |
6123456789 | ❌ | 10 digits: matches requires the whole text |
61234567a | ❌ | there’s a letter |
Spanish ID, the DNI (8 digits and a letter)
String dni = "\\d{8}[A-Za-z]";
This validates the format, not that the ID exists. The letter is computed from the remainder of dividing the number by 23, so to truly validate it the regex is only the first filter:
String letters = "TRWAGMYFPDXBNJZSQVHLCKE"; int number = 12345678; char letter = letters.charAt(number % 23); // 'Z'
You’ll see this a lot in real programming. The regex checks the shape, the code checks the meaning. Each tool for what it’s good at.
Email address
String email = "[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+"; // ^^^^^^^^^^ ^ ^^^^^^ ^^^^^^^^^^^^^ // | | | └─ one or more ".something": .com, .es, .ulpgc.es // | | └─ domain name // | └─ the at sign // └─ local part: letters, digits, _ . + -
Inside [ ], the dot, the + and the hyphen don’t need escaping: there they’re literal (the hyphen goes last so it isn’t read as a range).
| Text | Matches? |
|---|---|
sergio@sergiolearns.com | ✅ |
sergio.medina@alu.ulpgc.es | ✅ |
sergio@ | ❌ missing domain |
@domain.com | ❌ missing local part |
sergio@domain | ❌ missing the .com or similar |
a b@x.com | ❌ spaces aren’t allowed |
There is no perfect regex for emails; the real specification is huge. This one is a practical regex: it catches the obvious mistakes. The only way to know an email exists is to send it a message.
Second tool: Pattern and Matcher
matches is for validating and that’s where it ends. When you need to search several times, extract parts or reuse the pattern, you use two classes from java.util.regex:
Pattern: the compiled pattern. Created once.Matcher: the “searcher” that applies that pattern to one specific text.
import java.util.regex.Pattern;
import java.util.regex.Matcher;
Pattern p = Pattern.compile("\\d+"); // compile the pattern once
Matcher m = p.matcher("I have 3 cats and 12 fish"); // apply it to a text
matches, find and lookingAt: three different questions
| Method | Question | With "\\d+" and the text… |
|---|---|---|
matches() | does the whole text match? | "abc 123 def" → false |
find() | does some part match? (moves to the next one) | "abc 123 def" → true |
lookingAt() | does the start match? | "123abc" → true |
Matcher mm = Pattern.compile("\\d+").matcher("abc 123 def");
System.out.println(mm.matches()); // false
System.out.println(mm.find()); // true (finds "123")
System.out.println(mm.group()); // 123
Calling group() without having called find() or matches() and gotten true. It throws IllegalStateException: No match found.
Finding all matches: while (m.find())
Matcher m = Pattern.compile("\\d+").matcher("I have 3 cats and 12 fish");
while (m.find()) { // each find() jumps to the next match
System.out.println(m.group() + " at " + m.start() + "-" + m.end());
}
Output:
3 at 7-8 12 at 18-20
start() and end() give the positions (end excluded, like substring). Same idea with emails inside a text:
String text = "Write to sergio@sergiolearns.com or info@x.es today";
Matcher m = Pattern.compile("[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+").matcher(text);
while (m.find()) System.out.println(m.group());
// sergio@sergiolearns.com
// info@x.es
Groups: pulling parts out of the text
Parentheses, besides grouping, capture that part so you can read it later with group(n). Group 0 is the whole match; 1 is the first parenthesis, and so on.
Pattern p = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})"); // year-month-day
Matcher m = p.matcher("2026-10-07");
if (m.matches()) {
System.out.println(m.group(0)); // 2026-10-07 (everything)
System.out.println(m.group(1)); // 2026 (year)
System.out.println(m.group(2)); // 10 (month)
System.out.println(m.group(3)); // 07 (day)
System.out.println(m.groupCount()); // 3 (not counting 0)
}
Groups are numbered by the position of their opening parenthesis. With many groups it’s easy to lose count, so there are named groups:
Matcher m = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})").matcher("2026-10");
if (m.matches()) System.out.println(m.group("year") + "/" + m.group("month")); // 2026/10
In the email pattern, (\\.[\\w-]+)+ is also a group, but we use it only to repeat a chunk. If you don’t need to capture it, it’s harmless; just don’t count on it as a useful group(1).
Replacing and splitting with regex
String methods already accept regex:
"a1b22c333".replaceAll("\\d+", "#"); // "a#b#c#"
"Hello world yes".replaceAll("\\s+", " "); // "Hello world yes"
"a, b;c d".split("[,;\\s]+"); // [a, b, c, d]
And in the replacement you can reuse groups with $1, $2…:
"2026-10-07".replaceAll("(\\d{4})-(\\d{2})-(\\d{2})", "$3/$2/$1"); // "07/10/2026"
We convert one date format into another without cutting the text by hand. (Swap the order of $2/$3/$1 and you get the US format, 10/07/2026.)
Greedy or lazy: a detail that bites
The quantifiers + and * are greedy: they take as much as possible. Adding ? after them makes them lazy, taking as little as possible.
String html = "<b>one</b><b>two</b>";
// Greedy: .+ eats everything it can and backs off to the last </b>
Matcher g = Pattern.compile("<b>(.+)</b>").matcher(html);
g.find();
System.out.println(g.group(1)); // one</b><b>two
// Lazy: .+? stops at the first </b> it finds
Matcher l = Pattern.compile("<b>(.+?)</b>").matcher(html);
l.find();
System.out.println(l.group(1)); // one
Using .* or .+ expecting “up to the first closing tag” and getting everything up to the last one. If that happens, try .*? / .+?.
Options (flags) and checking passwords
To ignore case, there are two equivalent ways:
Pattern.compile("hello", Pattern.CASE_INSENSITIVE).matcher("HELLO").matches(); // true
"HELLO".matches("(?i)hello"); // true
And an example with lookahead, (?=...), which means “right here, what follows must be this” without consuming it. It lets you chain several rules over the same text, ideal for passwords: at least one uppercase letter, one lowercase letter, one digit and 8 characters.
String pw = "(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).{8,}";
"Hello1234".matches(pw); // true
"hello1234".matches(pw); // false: no uppercase
"Hello12".matches(pw); // false: fewer than 8
"HELLOhello".matches(pw); // false: no digit
You don’t need to master lookaheads now. Just recognize them when you see them: (?=...) means “require that what follows is…”.
When the regex is badly written
An invalid pattern doesn’t fail when your program compiles, but when it runs, with PatternSyntaxException:
try {
Pattern.compile("[a-z"); // missing closing ]
} catch (PatternSyntaxException e) {
System.out.println(e.getDescription()); // Unclosed character class
}
Forgetting to close [ or (. Since it’s a runtime error, you only find out when execution reaches that line: always test with valid and invalid cases.
Summary and quick table
| I want to… | Tool |
|---|---|
| Does the whole text follow the format? | text.matches(regex) |
| Is there something matching inside the text? | Pattern.compile(...).matcher(text).find() |
| All matches | while (m.find()) { m.group(); } |
| Extract parts | parentheses + m.group(n) or group("name") |
| Change text | replaceAll(regex, replacement) with $1, $2… |
| Split | split(regex) |
| Ignore case | (?i) or Pattern.CASE_INSENSITIVE |
Three ideas to keep:
- A regex lives in a String: every
\is written\\. matchesneeds the whole text;findsearches inside.- Parentheses group and capture; quantifiers are greedy unless you add
?.
In the next article you’ll put all this into practice with complete programs.
Next step: practiceRegular expressions in Java — practice with a form validator, a log reader and a text cleaner (and the invisible \r that broke every line)Did this article help you? Here is how you can support SergioLearns →
