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).

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:

  1. Validate: does this text follow the format? (email, ID, password…)
  2. Search and extract: find dates, numbers or emails inside a long text.
  3. 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

PatternMeansExample that matches
[abc]a, b or cb
[a-z]any lowercase letterq
[A-Za-z]any letter (no accents or ñ)Q
[0-9]a digit7
[^0-9]anything but a digitx

Shortcuts

ShortcutMeansIn Java
\da digit"\\d"
\wletter, digit or _"\\w"
\sa space, tab or line break"\\s"
.any character (except line break)"."

Quantifiers: how many times

QuantifierMeans
?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

SymbolMeans
^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
TextMatches?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).

TextMatches?
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

MethodQuestionWith "\\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 matcheswhile (m.find()) { m.group(); }
Extract partsparentheses + m.group(n) or group("name")
Change textreplaceAll(regex, replacement) with $1, $2…
Splitsplit(regex)
Ignore case(?i) or Pattern.CASE_INSENSITIVE

Three ideas to keep:

  1. A regex lives in a String: every \ is written \\.
  2. matches needs the whole text; find searches inside.
  3. 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 →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *