Regular expressions in Java — exercises with number plates, dates and logs (and the plate with vowels that slipped past me)
The first time I validated a Spanish number plate, I wrote \\d{4} [A-Z]{3} and felt pretty pleased with myself. It worked with 1234 BCD. It also worked with 1234 AEI and with 1234 QQQ. The problem is that current Spanish plates have no vowels, no Ñ and no Q. My regex accepted plates that can’t exist.
That’s the trap with regular expressions: a pattern that “works” with the case you tested can be far too permissive with everything else. In these exercises you’ll write precise patterns, test them with cases that should fail, and use groups to transform text. And as always, before the solutions there’s a section of code with a catch.
Table of Contents
How to use this article
- Read the statement and the expected output.
- Give it at least 15 minutes before opening the hint.
- Test your pattern with cases that should fail too: that’s where the bugs hide.
- Compare with the solution and read the reasoning behind each decision.
All solutions are static methods in a class called RegexExercises. You need import java.util.regex.Matcher; and import java.util.regex.Pattern;.
Level 1 — Basic: validate and normalize a number plate
Statement. Write String normalizePlate(String text). A current Spanish number plate has 4 digits and 3 consonants (no vowels, no Ñ and no Q). Users may type it without a separator, with a space or with a hyphen, and in lowercase. The method returns the plate in the format 1234 BCD, or null if it isn’t valid.
Expected output:
"1234BCD" -> 1234 BCD "1234 bcd" -> 1234 BCD "0000-XYZ" -> 0000 XYZ "1234 AEI" -> null "123 BCD" -> null "1234 QWR" -> null
Write out the list of allowed letters inside [ ] yourself instead of using [A-Z]. Use two groups (digits and letters) so you can rebuild the format. And convert to uppercase before validating.
Level 2 — Intermediate: dates to the international format
Statement. Write String datesToIso(String text), which finds every d/m/yyyy date inside a text (day and month may have 1 or 2 digits; this is the day-first format used in Spain) and turns it into yyyy-mm-dd, with leading zeros. If the day isn’t between 1 and 31, or the month between 1 and 12, the date is left as it was. Dates with a two-digit year aren’t touched.
Input: "Due: 7/3/2027. Exam: 15/01/2027. Wrong: 31/13/2027 and 3/4/27."
Expected output:
Due: 2027-03-07. Exam: 2027-01-15. Wrong: 31/13/2027 and 3/4/27.
With $3-$2-$1 you can’t add the missing zero in 7/3. Use the version of replaceAll that takes a function (you saw it in the practice article) and String.format("%02d", number).
Level 3 — Challenge: summarizing a log
Statement. Every line of this log has the format hh:mm:ss LEVEL ip message. Write void summary(String[] log) that:
- Ignores lines that don’t follow the format.
- Says how many valid lines there are and the time of the first and the last one.
- Lists every IP that produced at least one
ERROR, with how many errors, in the order they appear.
We haven’t covered collections yet, so use parallel arrays (as in the arrays article).
String[] log = {
"09:00:01 INFO 192.168.1.20 GET /",
"09:00:05 ERROR 10.0.0.7 failed login",
"09:00:09 WARN 192.168.1.20 slow response",
"garbage",
"09:01:12 ERROR 10.0.0.7 failed login",
"09:02:30 ERROR 172.16.0.3 error 500",
"09:03:00 INFO 10.0.0.7 GET /resources"
};
Expected output:
Valid lines: 6 (from 09:00:01 to 09:03:00) 10.0.0.7 -> 2 error(s) 172.16.0.3 -> 1 error(s)
A single pattern with named groups (time, level, ip, msg). For the IP, the inner group that only repeats .number can be non-capturing: (?:…). To count per IP: an array of IPs, an array of counters and a variable with how many distinct IPs you have so far.
What’s wrong here?
Three snippets that look correct. Before reading the explanation, try to predict what they print.
Snippet 1 — Is there a year in the text?
String text = "I passed FP2 in 2026 with an 8";
if (text.matches("\\d{4}")) {
System.out.println("There's a year");
} else {
System.out.println("No year at all");
}
It prints No year at all. matches() asks whether the whole text is 4 digits, and this text has much more in it. To know whether there’s a year inside, you need find(): Pattern.compile("\\d{4}").matcher(text).find() returns true. (text.matches(".*\\d{4}.*") also works, but it scans the text more clumsily and hides your intent.)
Using matches() when the question is “does it contain…?”. Quick rule: **validate = matches(); search = find()**.
Snippet 2 — An IP validator that accepts anything
String ip = "\\d{1,3}.\\d{1,3}.\\d{1,3}.\\d{1,3}";
System.out.println("192.168.1.20".matches(ip));
System.out.println("192x168y1z20".matches(ip));
System.out.println("1921681200".matches(ip));
It prints true three times. An unescaped dot means any character, so x, y and z are fine as separators. And in 1921681200 the “dots” eat digits: the regex splits it as 192, (1), 68, (1), 2, (0), 0, where the characters in brackets play the role of “dots”, and calls it a match. With \\., the second and third become false.
An unescaped dot in an IP, a version number (2.4.1) or a domain. It nearly always “works” with the good examples, so you only find out by testing bad ones.
Snippet 3 — The month that turned into a year
This pattern worked:
Matcher m = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})").matcher("2026-10-07");
m.matches();
System.out.println("month = " + m.group(2)); // month = 10
Then someone added a group to also capture “year-month” together:
Matcher m = Pattern.compile("((\\d{4})-(\\d{2}))-(\\d{2})").matcher("2026-10-07");
m.matches();
System.out.println("month = " + m.group(2)); // ???
Now it prints month = 2026. Groups are numbered by the position of their opening parenthesis: the new outer parenthesis is group 1, the year becomes group 2 and the month group 3. No error is thrown: the program keeps running and gives a wrong value. There are two fixes: named groups ((?<month>\\d{2})), which don’t move, or a non-capturing group (?:…) if the new parenthesis is only there to group.
Solutions
Level 1 — Number plate
// 4 digits + 3 consonants (no vowels, no Ñ or Q), with an optional space or hyphen
static final Pattern PLATE = Pattern.compile("(\\d{4})[ -]?([BCDFGHJKLMNPRSTVWXYZ]{3})");
static String normalizePlate(String text) {
Matcher m = PLATE.matcher(text.strip().toUpperCase());
if (!m.matches()) return null; // null = not a valid plate
return m.group(1) + " " + m.group(2); // one single format: "1234 BCD"
}
Why write out the letters by hand. [A-Z] accepts vowels and Q; [B-DF-HJ-NP-TV-Z] is shorter but almost unreadable. Writing out the 20 allowed consonants is long, but anyone can check at a glance what gets in and what doesn’t. In validation, readable beats clever.
**Why [ -]?.** The ? makes the separator optional: one pattern accepts “no separator”, “space” or “hyphen”. Inside [ ], a hyphen at the end is literal.
**Why toUpperCase() first rather than [A-Za-z].** The output comes out in uppercase with no extra work, and the pattern is half as long.
**Why return null.** It’s a way of saying “there’s no plate” without making up a value. The caller has to check it; when we get to exceptions you’ll see another way of reporting invalid data.
Level 2 — Dates
static final Pattern DATE = Pattern.compile("\\b(\\d{1,2})/(\\d{1,2})/(\\d{4})\\b");
static String datesToIso(String text) {
return DATE.matcher(text).replaceAll(m -> {
int day = Integer.parseInt(m.group(1));
int month = Integer.parseInt(m.group(2));
if (day < 1 || day > 31 || month < 1 || month > 12) return m.group(); // leave it as it was
return String.format("%s-%02d-%02d", m.group(3), month, day);
});
}
**Why \\b at both ends.** 3/4/27 never matches because the year needs 4 digits… but without the final \\b, in a text like 3/4/20271 the pattern would find 3/4/2027 inside a longer number. Word boundaries prevent “half” matches.
Why check the range in Java and not in the regex. You can write a valid month as a regex (0?[1-9]|1[0-2]), but days are worse, and even then 31/02 would pass. It’s the idea from the practice article: the regex finds the shape, the code checks the meaning. If the date makes no sense, we return m.group(), which is the original text untouched.
**Why %02d.** It pads with zeros to 2 digits: 3 → 03. That’s what $1 could never do, and the reason for using a function.
Level 3 — Log summary
static final Pattern LINE = Pattern.compile(
"(?<time>\\d{2}:\\d{2}:\\d{2}) (?<level>INFO|WARN|ERROR) (?<ip>\\d{1,3}(?:\\.\\d{1,3}){3}) (?<msg>.*)");
static void summary(String[] log) {
String[] ips = new String[log.length]; // parallel arrays: IP and number of errors
int[] errors = new int[log.length];
int distinct = 0, valid = 0;
String first = null, last = null;
for (String line : log) {
Matcher m = LINE.matcher(line.strip());
if (!m.matches()) continue; // "garbage" is skipped here
valid++;
if (first == null) first = m.group("time");
last = m.group("time"); // overwritten each time: the last one stays
if (!m.group("level").equals("ERROR")) continue;
String ip = m.group("ip");
int pos = -1;
for (int i = 0; i < distinct; i++) if (ips[i].equals(ip)) pos = i; // seen it before?
if (pos == -1) { pos = distinct; ips[distinct++] = ip; } // new IP at the end
errors[pos]++;
}
System.out.println("Valid lines: " + valid + " (from " + first + " to " + last + ")");
for (int i = 0; i < distinct; i++) System.out.println(ips[i] + " -> " + errors[i] + " error(s)");
}
**Why (?:…) inside the IP.** That parenthesis only repeats .number three times. If it were a normal group, there’d be an extra numbered group we’d never use. (?:…) groups without capturing: it’s the fix from snippet 3, applied from the start.
**Why continue.** It keeps the loop “flat”: each condition that rules a line out exits straight away, instead of nesting if inside if.
**Why the arrays are log.length long.** In the worst case, every line is an error from a different IP. It’s the same array + counter pattern you used to remove duplicates in the arrays exercises: we only look up to distinct, never up to ips.length.
**Why ips[i].equals(ip).** They’re Strings: with == you’d compare references (and you know how that ends).
Watch it step by step in Python Tutor
Let’s watch snippet 3 go wrong live. Open Python Tutor, choose Java, paste this code and step through it:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Groups {
public static void main(String[] args) {
Matcher a = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})").matcher("2026-10-07");
Matcher b = Pattern.compile("((\\d{4})-(\\d{2}))-(\\d{2})").matcher("2026-10-07");
a.matches();
b.matches();
String monthA = a.group(2);
String monthB = b.group(2);
System.out.println(monthA + " vs " + monthB);
}
}
Output:
10 vs 2026
What to watch:
- After creating
aandb, you’ll see **two differentMatcherobjects**, each with its own pattern, even though they work on the same text. - When
monthAis set, its value is"10"; on the next step,monthBis"2026". The samegroup(2)returns different things depending on where the parentheses are. - Change the first
(inbto(?:and run it again:monthBbecomes"10". - Also try deleting the
b.matches();line: when it reachesb.group(2)you get anIllegalStateException. AMatcherhas no groups until it finds something.
Cheat sheet: regular expressions in Java
The essentials in 30 seconds
- In Java the regex lives inside a String: every
\is written\\. - Validate (whole text) →
matches(). Search inside →find(). - Parentheses group and capture;
(?:…)groups without capturing;(?<name>…)captures with a name. - If a pattern is used many times:
static final Pattern P = Pattern.compile(…). - The regex checks the shape; calculations (the ID letter, days in a month) happen in Java.
Syntax
| I want… | Pattern (in Java) | Matches |
|---|---|---|
| One digit / several | "\\d" / "\\d+" | 7 / 2026 |
| Exactly n | "\\d{4}" | 1234 |
| Between n and m | "\\d{1,3}" | 7, 42, 255 |
| Optional | "colou?r" | color, colour |
| One of several characters | "[ -]" | space or hyphen |
| Anything except… | "[^0-9]" | x |
Letter, digit or _ | "\\w" | a, 7, _ |
| Whitespace | "\\s+" | spaces, tabs, line breaks |
| A real dot | "\\." | . |
| Word boundary | "\\b" | between a and a space |
| One or the other | "INFO|WARN|ERROR" | WARN |
| As little as possible | ".+?" | up to the first closing tag |
| Ignore case | "(?i)hello" | HELLO |
Pattern and Matcher
| I want to… | Code | Result |
|---|---|---|
| Compile once | static final Pattern P = Pattern.compile("\\d+"); | reusable pattern |
| Does the whole text match? | P.matcher(t).matches() | true/false |
| Is there anything inside? | P.matcher(t).find() | true/false |
| Loop over matches | while (m.find()) { m.group(); } | each match |
| Position | m.start() / m.end() | indexes (end excluded) |
| Captured part | m.group(1) / m.group("month") | the group’s text |
| Replace with groups | t.replaceAll("(\\d{2})/(\\d{2})/(\\d{4})", "$3-$2-$1") | 2027-01-15 |
| Replace with logic | P.matcher(t).replaceAll(m -> …) | whatever the function returns |
| Split | t.split("[,;\\s]+") | array of parts |
Templates from the exercises
Validate and normalize (number plate):
Matcher m = PLATE.matcher(text.strip().toUpperCase()); if (!m.matches()) return null; return m.group(1) + " " + m.group(2);
"1234 bcd" → 1234 BCD · "1234 AEI" → null
Transform with a function (dates):
DATE.matcher(text).replaceAll(m -> String.format("%s-%02d-%02d", m.group(3),
Integer.parseInt(m.group(2)), Integer.parseInt(m.group(1))));
7/3/2027 → 2027-03-07
Loop over lines and extract fields (log):
Matcher m = LINE.matcher(line.strip());
if (!m.matches()) continue;
String ip = m.group("ip");
Result: 10.0.0.7 -> 2 error(s) · 172.16.0.3 -> 1 error(s)
Most repeated errors
| Symptom | Likely cause |
|---|---|
illegal escape character when compiling | \d with a single backslash: in Java it’s \\d |
matches() is false for something that’s “right there” | The question was “does it contain?”: use find() |
| No line from a file matches | Invisible trailing spaces or \r: strip() first |
Accepts 192x168y1z20 as an IP | Unescaped dot: \\. |
group(2) returns something else after editing the pattern | Groups renumbered: use names or (?:…) |
IllegalStateException: No match found | group() without a find()/matches() that returned true |
| Grabs everything up to the last closing tag | Greedy quantifier: .+? |
PatternSyntaxException at runtime | Unclosed [ or ( |
A $ in the replacement disappears | In replacements $ means a group: \\$ or Matcher.quoteReplacement |
Before you submit, ask yourself
- Have I tested my pattern with cases that should fail, not only with good ones?
- Am I using
matches()to validate andfind()to search? - Have I escaped every dot and symbol I want to be literal?
- Is there any rule (ranges, check letters) I should be checking in Java rather than in the regex?
Did this article help you? Here is how you can support SergioLearns →
