Skip to main content
Java · lesson 16 of 66
Open the Java page →
Java intermediate Lesson 16 of 66

Regular Expressions in Java

Learn Java regex by running it: Pattern and Matcher, find vs matches, greedy vs lazy, groups, named groups, lookarounds, flags and computed replacements.

By SkillByExample · Updated

Java regex lives in java.util.regex. A Pattern is a compiled, immutable, thread-safe regex; a Matcher applies it to one input and holds the search state. Every example is a single file run with the JDK 21 source launcher (java File.java).

find() versus matches()

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RegexBasics {
    public static void main(String[] args) {
        Pattern digits = Pattern.compile("\\d+");

        Matcher m = digits.matcher("Order #1042 contains 3 items");
        while (m.find())
            System.out.println("found " + m.group() + " at " + m.start() + "-" + m.end());

        System.out.println(digits.matcher("12345").matches());
        System.out.println(digits.matcher("123x5").matches());
        System.out.println(digits.matcher("123x5").lookingAt());

        System.out.println("hello world".replaceAll("\\s+", "_"));
        System.out.println(java.util.Arrays.toString("a,b,,c,,".split(",")));
    }
}
$ java RegexBasics.java
found 1042 at 7-11
found 3 at 21-22
true
false
true
hello_world
[a, b, , c]

find walks through the input one match at a time; matches demands the whole string fit, and lookingAt only the start. Note split: the empty token between b and c survives, but trailing empty strings are silently dropped — pass a negative limit, split(",", -1), to keep them.

Syntax at a glance

SyntaxMeaningSyntaxMeaning
.any char except newline* + ?0+, 1+, 0-1 (greedy)
\d \w \sdigit, word char, whitespace{n,m}between n and m
\D \W \Stheir negations*? +?lazy versions
[abc] [^abc] [a-z]set, negated set, range^ $start / end (of line in MULTILINE)
(...) (?:...)capturing / non-capturing\bword boundary

In Java source every backslash is doubled: the regex \d+ is written "\\d+".

Greedy versus lazy

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class GreedyLazy {
    public static void main(String[] args) {
        String html = "<b>bold</b> and <i>italic</i>";

        Matcher greedy = Pattern.compile("<.+>").matcher(html);
        if (greedy.find()) System.out.println("greedy: " + greedy.group());

        Matcher lazy = Pattern.compile("<.+?>").matcher(html);
        while (lazy.find()) System.out.println("lazy:   " + lazy.group());
    }
}
$ java GreedyLazy.java
greedy: <b>bold</b> and <i>italic</i>
lazy:   <b>
lazy:   </b>
lazy:   <i>
lazy:   </i>

.+ grabs as much as it can and then backs off only far enough to find a final > — the last one, so it swallows the whole string. .+? stops at the first > that lets the match succeed, giving one tag per match.

Capturing groups

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class GroupsDemo {
    public static void main(String[] args) {
        Pattern date = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
        Matcher m = date.matcher("Event on 2024-07-15 starts at 09:00");
        if (m.find()) {
            System.out.println("whole: " + m.group(0) + ", groups: " + m.groupCount());
            System.out.println("year " + m.group(1) + ", month " + m.group(2) + ", day " + m.group(3));
        }

        Matcher pm = Pattern.compile("\\$(\\d+\\.\\d{2})").matcher("Cart: $12.99 + $3.50 + $0.99");
        double total = 0;
        while (pm.find()) total += Double.parseDouble(pm.group(1));
        System.out.printf("total: $%.2f%n", total);

        System.out.println("2024-07-15".replaceAll("(\\d{4})-(\\d{2})-(\\d{2})", "$3/$2/$1"));
    }
}
$ java GroupsDemo.java
whole: 2024-07-15, groups: 3
year 2024, month 07, day 15
total: $17.48
15/07/2024

Group 0 is always the whole match; groups 1..n are numbered by their opening parenthesis. The price pattern escapes $ because a bare $ means end-of-input, and captures only the digits. In a replacement string, $1, $2, $3 refer back to the groups, which reorders the date.

Named groups

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class NamedGroups {
    static final Pattern LOG = Pattern.compile(
        "\\[(?<date>[\\d-]+) (?<time>[\\d:]+)\\] (?<level>\\w+) (?<logger>[\\w.]+): (?<message>.+)");

    public static void main(String[] args) {
        Matcher m = LOG.matcher("[2024-07-15 14:32:01] ERROR UserService: User 42 not found");
        if (m.matches()) {
            System.out.println("level   = " + m.group("level"));
            System.out.println("logger  = " + m.group("logger"));
            System.out.println("message = " + m.group("message"));
        }

        Pattern ssn = Pattern.compile("(?<first>\\d{3})-(?<mid>\\d{2})-(?<last>\\d{4})");
        System.out.println(ssn.matcher("SSN: 123-45-6789").replaceAll("***-**-${last}"));
    }
}
$ java NamedGroups.java
level   = ERROR
logger  = UserService
message = User 42 not found
SSN: ***-**-6789

(?<name>...) lets code ask for group("level") instead of counting parentheses, so adding a group later does not silently shift the numbers. Replacements refer to a named group as ${name}.

Lookahead and lookbehind

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class LookaroundDemo {
    static void show(String label, String regex, String input) {
        Matcher m = Pattern.compile(regex).matcher(input);
        StringBuilder sb = new StringBuilder();
        while (m.find()) sb.append(m.group()).append(' ');
        System.out.println(label + ": " + sb.toString().strip());
    }

    public static void main(String[] args) {
        show("followed by px", "\\d+(?=px)", "font-size: 16px; margin: 8em; top: 4px");
        show("not followed by px", "\\b\\d+(?!\\d|px)", "font-size: 16px; margin: 8em; top: 4px");
        show("after $", "(?<=\\$)\\d+\\.\\d{2}", "Price: $29.99, Fee: 5.00, Discount: $5.00");
        show("no minus before", "(?<!-)\\b\\d+", "temps: -3 12 -7 25");
    }
}
$ java LookaroundDemo.java
followed by px: 16 4
not followed by px: 8
after $: 29.99 5.00
no minus before: 12 25

Lookarounds check context without consuming it, so px and $ are tested but never appear in the match. (?=...)/(?!...) look ahead, (?<=...)/(?<!...) look behind. The \d inside the second lookahead matters: without it \d+ would backtrack and match just the 1 of 16, because 1 is followed by 6, not by px.

Validation patterns and flags

import java.util.regex.Pattern;

public class CommonPatterns {
    static final Pattern EMAIL = Pattern.compile("[\\w.%+-]+@[\\w.-]+\\.[a-zA-Z]{2,}");
    static final Pattern ISO_DATE = Pattern.compile("\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])");
    static final Pattern PASSWORD = Pattern.compile("(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*]).{8,}");

    public static void main(String[] args) {
        System.out.println(EMAIL.matcher("alice@example.com").matches() + " " + EMAIL.matcher("not-an-email").matches());
        System.out.println(ISO_DATE.matcher("2024-12-01").matches() + " " + ISO_DATE.matcher("2024-13-01").matches());
        System.out.println(PASSWORD.matcher("Passw0rd!").matches() + " " + PASSWORD.matcher("password").matches());

        Pattern hello = Pattern.compile("^hello", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
        System.out.println(hello.matcher("Hi\nHELLO there").find());
        System.out.println("Hi\nHELLO there".matches("(?is).*hello.*"));
    }
}
$ java CommonPatterns.java
true false
true false
true false
true
true

With matches() the pattern is anchored at both ends, so no ^/$ is needed. The password rule uses three lookaheads to require one of each character kind anywhere, then .{8,} for length. MULTILINE lets ^ match after the newline and CASE_INSENSITIVE accepts HELLO; the inline form (?is) turns on case-insensitivity and DOTALL (. matches \n) where no flags argument exists.

Computed replacements

import java.util.regex.Pattern;

public class ReplaceWithLogic {
    public static void main(String[] args) {
        String squared = Pattern.compile("\\d+")
            .matcher("values: 3 and 5 and 12")
            .replaceAll(mr -> String.valueOf(Integer.parseInt(mr.group()) * Integer.parseInt(mr.group())));
        System.out.println(squared);

        String log = "Charged card 4111-1111-1111-1234 for $99.00";
        String redacted = Pattern.compile("\\b(?:\\d{4}[- ]?){3}(\\d{4})\\b")
            .matcher(log)
            .replaceAll(mr -> "****-****-****-" + mr.group(1));
        System.out.println(redacted);

        System.out.println("cost: 5".replaceAll("\\d", "\\$1"));
    }
}
$ java ReplaceWithLogic.java
values: 9 and 25 and 144
Charged card ****-****-****-1234 for $99.00
cost: $1

replaceAll(Function<MatchResult,String>) (Java 9+) computes each replacement from the match. In a plain replacement string, $ and \ are special, so a literal dollar sign must be written \\$ — which is why the last line prints the literal text $1 rather than a group reference. Wrap replacement text that comes from user input with Matcher.quoteReplacement.

Practice

  1. Extract every hashtag (# followed by word characters) from a tweet, without the #.
Solution
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Hashtags {
    public static void main(String[] args) {
        Matcher m = Pattern.compile("#(\\w+)").matcher("Loving #Java21 and #regex! #100DaysOfCode");
        List<String> tags = new ArrayList<>();
        while (m.find()) tags.add(m.group(1));
        System.out.println(tags);
    }
}
$ java Hashtags.java
[Java21, regex, 100DaysOfCode]
  1. Convert camelCaseNames to snake_case with one replaceAll and a lookahead.
Solution
public class SnakeCase {
    public static void main(String[] args) {
        for (String s : new String[] {"camelCaseNames", "userId", "parseHTTPResponse"})
            System.out.println(s + " -> " + s.replaceAll("(?<=[a-z0-9])(?=[A-Z])", "_").toLowerCase());
    }
}
$ java SnakeCase.java
camelCaseNames -> camel_case_names
userId -> user_id
parseHTTPResponse -> parse_httpresponse

The pattern matches the empty position between a lowercase letter and an uppercase one, so the replacement inserts _ without consuming any characters. HTTPResponse stays one word because there is no lowercase letter between its capitals.

  1. Parse key=value pairs separated by ; (with optional spaces) into a LinkedHashMap using named groups.
Solution
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class KeyValues {
    public static void main(String[] args) {
        Pattern kv = Pattern.compile("(?<key>\\w+)\\s*=\\s*(?<value>[^;]+?)\\s*(?:;|$)");
        Matcher m = kv.matcher("host = db.local; port=5432 ;user= admin");
        Map<String, String> map = new LinkedHashMap<>();
        while (m.find()) map.put(m.group("key"), m.group("value"));
        System.out.println(map);
    }
}
$ java KeyValues.java
{host=db.local, port=5432, user=admin}

Frequently Asked Questions

What is the difference between matches() and find() in Java regex?
Matcher.matches() and String.matches() succeed only if the whole input matches the pattern. Matcher.find() scans for the next match anywhere in the input and can be called in a loop to visit every match. The pattern \d+ finds 42 inside 'price: 42' with find(), but matches() on the same input returns false.
Why do Java regex patterns need double backslashes?
The backslash is an escape character in Java string literals and in regex syntax. To pass the regex \d to the regex engine, the Java source must contain "\\d" so the string literal yields a single backslash followed by d. Text blocks use the same escape rules, so they do not remove the doubling either.
How do I make a Java regex case-insensitive?
Either pass the flag when compiling, as in Pattern.compile("hello", Pattern.CASE_INSENSITIVE), or put the inline flag (?i) at the start of the pattern. The inline form also works with String.matches and replaceAll, which take a pattern string and offer no flags parameter.
What is a non-capturing group in regex?
A capturing group, written (abc), records the text it matched so you can read it with Matcher.group(n) or refer to it as $n in a replacement. A non-capturing group, written (?:abc), only groups for alternation or quantifiers and is not numbered, which keeps group numbers stable when you add grouping for structure.
Should I compile a Pattern once or call String.matches each time?
Compile once and reuse. String.matches, replaceAll and split compile the pattern on every call, which is wasteful in loops. A Pattern object is immutable and thread-safe, so a static final Pattern can be shared; a Matcher holds per-search state and must not be shared between threads.