A regular expression, or regex, is a small pattern language for describing sets of strings. Instead of writing five if statements to check whether a coupon code looks valid, you write one pattern like [A-Z]{4}-\d{4} and let Java do the matching. This lesson covers the regex API in java.util.regex, the four String shortcut methods that take a regex, the core syntax you need on the job, and two common pitfalls: double escaping and catastrophic backtracking.
Consider an e-commerce site that lets customers redeem coupons. A valid coupon looks like SAVE-1234: four uppercase letters, a hyphen, four digits. Without regex, validating it by hand looks like this.
That's a lot of code for a simple rule. Worse, the rule is buried inside the loops. If marketing later wants SAVE-12345 (five digits instead of four), you'd have to read the code carefully to figure out where to change it.
The same check with regex:
The pattern [A-Z]{4}-\d{4} reads as "four uppercase letters, a hyphen, four digits". The rule is right there in one line, in a language designed for exactly this kind of check. The rest of this lesson shows what's inside that pattern and how to use it well.
Four methods on String accept a regex as their first argument. They're convenient for one-off checks.
| Method | What it does |
|---|---|
matches(String regex) | Returns true if the entire string matches the pattern |
replaceAll(String regex, String replacement) | Replaces every match with replacement |
replaceFirst(String regex, String replacement) | Replaces only the first match |
split(String regex) | Splits the string at every match and returns the pieces as a String[] |
The most important thing to understand about matches is that it must match the entire string, not just a piece of it. If you want to check whether a string contains the pattern somewhere inside, you need Pattern.matcher(...).find(), which we cover later.
The second call returns false because the entire input is SAVE-1234, not just SAVE. The third call wraps the literal in .* (zero or more of any character), so it matches the whole input even though SAVE only appears at the start.
replaceAll swaps out every match. A common use case is redacting digits in a log line.
split is handy for chopping up structured strings.
Real-world CSV has quoted fields, escaped commas inside quotes, and other edge cases that regex alone can't handle cleanly. For production CSV parsing, use a library like OpenCSV or Apache Commons CSV. For simple inputs that you control, split(",") is fine.
Every call to String.matches, replaceAll, replaceFirst, or split compiles the regex from scratch. That's fine if you call it once. Inside a loop that runs ten thousand times, it can dominate the loop's cost.
Pattern and MatcherThe shortcut methods are convenient but wasteful when you run the same regex many times. The proper API lives in java.util.regex and has two main classes.
Pattern is the compiled form of a regex. You build it once with Pattern.compile(regex).Matcher is the engine that runs a compiled Pattern against a specific input string. You get one by calling pattern.matcher(input).A coupon validator that processes a list of inputs without recompiling the pattern:
The pattern is compiled exactly once, outside the loop. Each iteration creates a fresh Matcher, which is cheap. Matcher objects hold state about where they are in the input, so they aren't safe to share across threads. The Pattern itself is immutable and thread-safe, which is the whole point of separating the two.
The lifecycle laid out as a diagram:
Matcher has three core methods that ask different questions of the input.
| Method | Question it answers |
|---|---|
matches() | Does the whole input match the pattern? |
find() | Is there a match somewhere in the remaining input? Advances the cursor on each call. |
lookingAt() | Does the input match starting from position 0, even if extra characters follow? |
find() is the most common method for scanning a string for occurrences. It walks the input from where it left off, returns true if it found a match, and lets you ask for the matched text with group().
group() returns the substring that the most recent find() matched. start() returns its starting index in the input, and end() returns the index just past the end. This is the standard pattern for extracting structured tokens from free-form text.
A diagram of how the cursor moves through the input as find() is called repeatedly:
The cursor never goes backward. Each successful find() leaves it pointing just past the last match, so the next call starts searching from there.
The pattern language is the part that takes practice. The core syntax that covers most Java work:
Most characters in a regex stand for themselves. The pattern cart matches the four letters c, a, r, t in order. The exception is a small set of metacharacters that have special meanings: . * + ? ( ) [ ] { } \ ^ $ |. To match one of these literally, prefix it with a backslash. To match a literal $, write \$. To match a literal ., write \..
A character class matches exactly one character from a set you define.
| Class | Matches |
|---|---|
[abc] | The literal a, b, or c |
[a-z] | Any single lowercase letter |
[A-Za-z] | Any single ASCII letter |
[^0-9] | Any single character that is not a digit |
The caret ^ at the start of a character class negates it. Outside a class, ^ means start of input.
Some sets come up so often that regex gives them shorthand.
| Shorthand | Equivalent | Matches |
|---|---|---|
\d | [0-9] | A single digit |
\D | [^0-9] | Any single non-digit |
\w | [A-Za-z0-9_] | A single word character |
\W | [^A-Za-z0-9_] | A single non-word character |
\s | [ \t\n\r\f] | A single whitespace character |
\S | [^\s] | A single non-whitespace character |
. | (any except newline) | Any one character, except a newline by default |
Anchors don't match characters. They match positions.
| Anchor | Matches at |
|---|---|
^ | The start of the input |
$ | The end of the input |
\b | A word boundary (between \w and \W) |
\b is the one to know. It's how you say "match the whole word cart, not the cart inside cartridge".
A quantifier follows a character or group and says how many times it can repeat.
| Quantifier | Meaning |
|---|---|
* | Zero or more times |
+ | One or more times |
? | Zero or one time (optional) |
{n} | Exactly n times |
{n,m} | Between n and m times |
{n,} | At least n times |
By default, quantifiers are greedy: they match as many characters as possible. Adding a ? after the quantifier makes it reluctant (also called lazy), matching as few as possible.
The greedy .+ swallows everything between the first < and the last >. The reluctant .+? stops at the first > it can. Use reluctant quantifiers when you want to match the smallest enclosing token.
The | operator means "or". The pattern cart|wishlist matches either cart or wishlist. Parentheses group the alternatives: (cart|wishlist) page matches cart page or wishlist page.
A single example that uses several of these together:
The pattern says: a word boundary, then either one-or-more digits or the literal no, then whitespace, then a word, then another word boundary. That's enough to pick up "12 stickers" and "no batteries" without grabbing stray digits inside numbers we don't care about.
Parentheses do two jobs at once. They group part of the pattern so a quantifier can apply to the whole group, and they capture what was matched so you can pull it out later.
Capturing groups are numbered from 1, left to right by the position of their opening parenthesis. group(0) returns the whole match.
Once you have more than two or three groups, counting positions gets fragile. A named group lets you label the capture with a name and refer to it that way. The syntax is (?<name>...), and you read it with group("name").
If a set of parentheses is only there to group for a quantifier and you don't care about capturing what it matched, use a non-capturing group (?:...). It's slightly faster because the engine doesn't have to record the match, and it keeps your group numbers tidy.
The (?:ORDER|INVOICE) group exists only to attach | to two alternatives. We don't need to capture it, so we mark it non-capturing. That makes group(1) refer to the digits, which is what we actually want.
Inside the same pattern, \1 refers back to whatever was captured by group 1. This is useful for finding repeated tokens, like duplicate words.
\1 matches the same text that group 1 captured earlier in the same match. Back-references are powerful but can trigger slow matching on long inputs, so use them sparingly.
This is the single biggest source of regex bugs in Java. The regex syntax uses \ to introduce special tokens (\d, \s, \b). Java string literals also use \ for their own escape sequences (\n, \t, \\). So when you write a regex in a Java string literal, the backslash needs to survive two passes: once through the Java compiler, and then through the regex compiler.
The rule is simple. Every \ in the regex becomes \\ in the Java string literal.
| Regex you want | Java string literal | What it matches |
|---|---|---|
\d | "\\d" | A single digit |
\s | "\\s" | A single whitespace character |
\. | "\\." | A literal period |
\\ | "\\\\" | A literal backslash |
\b | "\\b" | A word boundary |
Forgetting this gives you either a compile error (for unknown escape sequences like "\d") or a runtime PatternSyntaxException.
What's wrong with this code?
The Java compiler reads "\$\d+\.\d{2}" and complains that \$, \d, and \. are not valid Java string escapes. The code doesn't even compile.
Fix:
Each \\ in the source code becomes one \ in the actual string at runtime, which is what the regex engine sees and expects.
Java 15 added text blocks, which use triple quotes and don't process backslash escapes the way regular string literals do. Inside a text block, the regex looks like the regex.
Text blocks help for any regex more than a handful of characters long, because they cut the visual noise of double backslashes everywhere.
The Java compiler doesn't know your string is a regex, so it can't catch a malformed pattern at compile time. A regex error becomes a PatternSyntaxException at runtime, when Pattern.compile runs. If the pattern is hard-coded, compile it once in a static final Pattern field so the failure happens at class load instead of deep inside a request handler.
Regex matching does real work internally. Two cost patterns matter in production code.
The String shortcut methods (matches, replaceAll, etc.) compile the regex every time you call them. For a single check, the cost is negligible. For a hot loop, it adds up.
Exact numbers vary by machine and JVM, but the compiled version is several times faster on any setup. The rule of thumb: if the regex is fixed at compile time and you'll run it more than a handful of times, lift it into a static final Pattern field.
A poorly designed pattern can take exponential time on an input that doesn't match. The regex engine tries one path, fails, backs up, tries another, and the combinations explode.
The classic shape is nested quantifiers with overlap, like (a+)+. Each a in the input can be partitioned across the inner and outer quantifier in many ways, and a failure at the end forces the engine to try them all.
Consider an e-commerce flavored example. To validate that a product description contains only words and spaces with ^(\w+\s*)+$. On an input that almost matches but has an invalid character at the end, the engine grinds.
Eight seconds for a 33-character input. Add a few more as and you'll wait for hours. The fix is to either rewrite the pattern so the alternatives don't overlap, or use a possessive quantifier, which refuses to give back characters once it has matched them.
The ++ says "match one or more, and never give any back". With backtracking disabled at that point, there's nothing for the engine to explode over. It fails fast.
A simpler fix is often to write the pattern more carefully in the first place. ^\w+(\s+\w+)*$ matches the same shape (words separated by whitespace) without the overlap.
Patterns that work fine in testing can hang a thread on adversarial input. If your regex runs on user-supplied data, either use possessive quantifiers, set a timeout on a separate thread, or rewrite the pattern to remove ambiguity. Tools like regex101.com and regexr.com can show backtracking step counts before you ship.
. SurpriseThe most common single-character mistake is forgetting that . matches any character, not just a literal period.
What's wrong with this code?
The author wanted to replace literal dots, of which there are none, expecting the output to be ORD-12345 unchanged. Actual output:
. matched every character. The pattern says "any character", and replaceAll replaced all of them.
Fix:
Escape the dot to make it literal.
Now the pattern matches a literal period, of which the input has none, so nothing changes. If you wanted to replace every character with *, use . on purpose, but knowing what it does.
A realistic example that ties most of this together. An order log line contains a card number that needs to be redacted before the log gets written to disk, but the last four digits should remain visible so support staff can confirm the card with the customer.
The pattern splits the 16-digit card into a 12-digit prefix (group 1) and a 4-digit suffix (group 2). replaceAll uses $2 to reinsert the captured suffix while replacing the prefix with twelve asterisks. The \b word boundaries make sure we don't accidentally match a 16-digit substring inside something larger like an order ID.
This is the regex sweet spot: a clear rule, a one-line pattern, and a transformation that would take 30 lines of manual character handling.
The syntax table again, all in one place for scanning.
| Category | Token | Meaning | Java string form |
|---|---|---|---|
| Literal | a | The character a | "a" |
| Escape | \. \$ \( | Literal . $ ( | "\\." "\\$" "\\(" |
| Class | [abc] | One of a, b, c | "[abc]" |
| Class | [a-z] | One lowercase letter | "[a-z]" |
| Class | [^0-9] | Any non-digit | "[^0-9]" |
| Predefined | \d | A digit | "\\d" |
| Predefined | \D | A non-digit | "\\D" |
| Predefined | \w | Word character ([A-Za-z0-9_]) | "\\w" |
| Predefined | \W | Non-word character | "\\W" |
| Predefined | \s | Whitespace | "\\s" |
| Predefined | \S | Non-whitespace | "\\S" |
| Predefined | . | Any character except newline | "." |
| Anchor | ^ | Start of input | "^" |
| Anchor | $ | End of input | "$" |
| Anchor | \b | Word boundary | "\\b" |
| Quantifier | * | Zero or more (greedy) | "*" |
| Quantifier | + | One or more (greedy) | "+" |
| Quantifier | ? | Zero or one (greedy) | "?" |
| Quantifier | {n} | Exactly n | "{n}" |
| Quantifier | {n,m} | Between n and m | "{n,m}" |
| Quantifier | *? +? ?? | Reluctant variants | "*?" |
| Quantifier | *+ ++ ?+ | Possessive variants | "*+" |
| Alternation | a|b | a or b | "a|b" |
| Group | (...) | Capturing group | "(...)" |
| Group | (?:...) | Non-capturing group | "(?:...)" |
| Group | (?<name>...) | Named capturing group | "(?<name>...)" |
| Back-ref | \1 | Whatever group 1 matched | "\\1" |
For the full grammar, including Unicode property escapes and POSIX classes, see the Pattern Javadoc at <https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/regex/Pattern.html>.
10 quizzes