Regular expressions in Java — practice with a form validator, a log reader and a text cleaner (and the invisible \r that broke every line)

My first log reader in Java didn’t recognize a single line. Not one. The pattern was right: I had tested it with a line I’d copied by hand and it worked. But when I read the real file, every line came out as “invalid format”.

The culprit was invisible: the file came from Windows, and every line ended in \r. Since matches() requires the pattern to cover the whole text, that one extra character at the end was enough for nothing to match. A strip() before validating and the problem was gone. Since then, before blaming the regex, I check what the text really contains.

In the theory article you saw the pieces: matches, Pattern, Matcher, find, groups and replaceAll. Here we put them together in three complete programs, the kind you could use in a real assignment:

  1. A form validator (username, email, phone, Spanish ID with its check letter, and password).
  2. A log reader that splits each line into fields and looks for IP addresses inside the message.
  3. A text cleaner that fixes spacing, dates and phone numbers, and hides email addresses.

Program 1: a sign-up form validator

Picture a website’s sign-up form: five fields, each with its own rule. We want a list of all the errors at once, not just the first one, because that’s what the person filling in the form expects.

import java.util.regex.Pattern;

public class FormValidator {

    // Compile each pattern ONCE: it's reused on every validation
    static final Pattern USERNAME = Pattern.compile("[a-z][a-z0-9_]{3,15}");
    static final Pattern EMAIL    = Pattern.compile("[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+");
    static final Pattern PHONE    = Pattern.compile("[6-9]\\d{8}");
    static final Pattern DNI      = Pattern.compile("(\\d{8})([A-Z])");
    static final Pattern PASSWORD = Pattern.compile("(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).{8,}");
    static final String DNI_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE";

    static String validate(String username, String email, String phone, String dni, String password) {
        String errors = "";
        if (!USERNAME.matcher(username).matches()) errors += "  - invalid username\n";
        if (!EMAIL.matcher(email).matches())       errors += "  - invalid email\n";
        // users may type spaces: remove them before validating
        if (!PHONE.matcher(phone.replaceAll("\\s+", "")).matches()) errors += "  - invalid phone\n";
        if (!validDni(dni.toUpperCase()))          errors += "  - invalid DNI\n";
        if (!PASSWORD.matcher(password).matches()) errors += "  - weak password\n";
        return errors;
    }

    static boolean validDni(String dni) {
        var m = DNI.matcher(dni);
        if (!m.matches()) return false;                  // first, the SHAPE
        int number = Integer.parseInt(m.group(1));       // group 1: the 8 digits
        char letter = m.group(2).charAt(0);              // group 2: the letter
        return DNI_LETTERS.charAt(number % 23) == letter; // then, the MEANING
    }

    public static void main(String[] args) {
        String[][] records = {
            {"sergio_43", "sergio@sergiolearns.com", "612 345 678", "12345678z", "Regex2026"},
            {"4dmin", "admin@", "512345678", "12345678A", "admin"},
            {"ana", "ana.lopez@alu.ulpgc.es", "928123456", "87654321X", "MyPass12"}
        };
        for (String[] r : records) {
            String errors = validate(r[0], r[1], r[2], r[3], r[4]);
            System.out.println(r[0] + ":" + (errors.isEmpty() ? " all good" : "\n" + errors.stripTrailing()));
        }
    }
}

Output:

sergio_43: all good
4dmin:
  - invalid username
  - invalid email
  - invalid phone
  - invalid DNI
  - weak password
ana:
  - invalid username

The phone and ID rules are Spanish (9-digit numbers starting with 6–9, and the DNI: 8 digits plus a check letter). The technique is the same for any format; only the pattern changes.

Why are the patterns static final?

Because Pattern.compile translates the regex into an internal structure, and that has a cost. If we did it inside validate, the work would be repeated for every record. With static final, it’s compiled once when the class loads, and after that we only create a cheap Matcher for each text. "text".matches(regex) is handy, but internally it compiles the pattern every time you call it.

As soon as a pattern is used inside a loop or in a method that’s called often, move it into a static final Pattern constant. It’s the habit you’ll see in any professional codebase.

Why is ana invalid if her email and ID are fine?

Her username has 3 letters, and the pattern asks for a first letter plus 3 to 15 more characters: 4 at least. That’s exactly what should happen: a regex doesn’t “round up”. If you wanted to accept 3-letter names, the change is one number: {2,15}.

And the lowercase ID in the first record?

12345678z passes because we call dni.toUpperCase() before validating. Normalizing first (uppercase, no spaces) and validating afterwards keeps the pattern simple. The alternative would be putting [A-Za-z] in the regex and then converting the letter anyway to compare it: more mess for the same result.

Notice the division of labour in validDni: the regex checks the shape (8 digits and a letter), and the groups give us the pieces to check the meaning (that the letter is the right one). 12345678A has the right shape, but the correct letter is Z, so it fails.

Trying to put the ID letter check inside the regex. It can’t be done: a regular expression can’t do arithmetic. When a rule needs a calculation, the regex extracts the data and the code does the maths.

Program 2: a log reader with named groups

A log is a file where a program writes down what’s happening, one line per event. Every line follows the same format, which is perfect for a regex: one single expression splits each line into its fields.

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

public class LogReader {

    // date time LEVEL [module] message
    static final Pattern LINE = Pattern.compile(
        "(?<date>\\d{4}-\\d{2}-\\d{2}) (?<time>\\d{2}:\\d{2}:\\d{2}) " +
        "(?<level>INFO|WARN|ERROR) \\[(?<module>\\w+)\\] (?<message>.*)");
    static final Pattern IP = Pattern.compile("\\b\\d{1,3}(\\.\\d{1,3}){3}\\b");

    public static void main(String[] args) {
        String[] log = {
            "2026-10-07 09:15:02 INFO [auth] Successful login for sergio from 192.168.1.20",
            "2026-10-07 09:15:40 ERROR [auth] Wrong password for admin from 10.0.0.7",
            "2026-10-07 09:16:03 WARN [db] Slow query: 1840 ms",
            "broken line with no format",
            "2026-10-07 09:17:11 ERROR [auth] Wrong password for admin from 10.0.0.7",
            "2026-10-07 09:18:55 INFO [web] Page /resources served in 35 ms\r"
        };
        int info = 0, warn = 0, error = 0, broken = 0;

        for (String line : log) {
            Matcher m = LINE.matcher(line.strip());       // strip(): removes trailing spaces and \r
            if (!m.matches()) { broken++; continue; }

            switch (m.group("level")) {
                case "INFO" -> info++;
                case "WARN" -> warn++;
                case "ERROR" -> error++;
            }
            if (m.group("level").equals("ERROR")) {
                Matcher ip = IP.matcher(m.group("message"));   // search INSIDE the message
                String source = ip.find() ? ip.group() : "no IP";
                System.out.println(m.group("time") + " ERROR in " + m.group("module") + " from " + source);
            }
        }
        System.out.println("INFO: " + info + " | WARN: " + warn + " | ERROR: " + error + " | broken lines: " + broken);
    }
}

Output:

09:15:40 ERROR in auth from 10.0.0.7
09:17:11 ERROR in auth from 10.0.0.7
INFO: 2 | WARN: 1 | ERROR: 2 | broken lines: 1

Why named groups instead of group(1), group(2)…?

With five groups, m.group(4) tells the reader nothing; m.group("module") tells them everything. And there’s a less obvious advantage: if tomorrow you add a new group at the start of the pattern, the numbers shift and every piece of code that used numbers breaks without any error. Names don’t move.

Why matches() for the line and find() for the IP?

They’re two different questions. For the line we ask “does the whole thing have the format of a log entry?”, which is matches(). For the IP we ask “is there any IP inside this message?”, which is find(). We also search only inside m.group("message"), not the whole line, so a date or a time can never be mistaken for an IP.

The \\b in the IP pattern is a word boundary: it requires that there’s no other digit or letter right before and after. Without it, in a text like 1192.168.1.200 you’d find 192.168.1.200 sitting inside a longer number.

What happens to the line that ends in \r?

That’s the one from my story at the start. Without strip(), that last line would count as broken, because .* doesn’t accept \r (it’s a line terminator) and matches() needs to reach the end. With strip() it counts as one more INFO, which is why you get 2.

Reading lines from a file created on Windows and validating them with matches() without cleaning them first. It works with the text you paste into your code and fails with the real file. If “no line matches”, suspect invisible spaces and \r first.

A detail about the switch

We use the arrow switch (case "INFO" -> info++;) from the control flow article. Since the pattern only accepts INFO, WARN or ERROR, we know no other value can arrive: the regex has already filtered the input.

Program 3: a text cleaner with replaceAll

The last program doesn’t validate or extract: it transforms. It takes a text written in a hurry and makes it presentable.

import java.util.regex.Pattern;

public class TextCleaner {

    static String clean(String text) {
        String t = text.strip();
        t = t.replaceAll("\\s+", " ");                     // 1. a single space between words
        t = t.replaceAll(" ([,.;:!?])", "$1");             // 2. no space BEFORE punctuation
        t = t.replaceAll("\\b(\\d{2})/(\\d{2})/(\\d{4})\\b", "$3-$2-$1");   // 3. dates to yyyy-mm-dd
        t = t.replaceAll("\\b([6-9]\\d{2})[ -]?(\\d{3})[ -]?(\\d{3})\\b", "$1 $2 $3");  // 4. phone numbers
        return t;
    }

    static String hideEmails(String text) {
        // replaceAll with a function: it decides the replacement for each match
        return Pattern.compile("([\\w.+-])([\\w.+-]*)@([\\w.-]+)")
                      .matcher(text)
                      .replaceAll(m -> m.group(1) + "*".repeat(m.group(2).length()) + "@" + m.group(3));
    }

    public static void main(String[] args) {
        String messy = "  Hi   ,  I'm Sergio .  Call me on 612-345-678 or 928123456 ;"
                     + " exam on 15/01/2027 .  Write to sergio@sergiolearns.com  ";
        String cleaned = clean(messy);
        System.out.println(cleaned);
        System.out.println(hideEmails(cleaned));
    }
}

Output:

Hi, I'm Sergio. Call me on 612 345 678 or 928 123 456; exam on 2027-01-15. Write to sergio@sergiolearns.com
Hi, I'm Sergio. Call me on 612 345 678 or 928 123 456; exam on 2027-01-15. Write to s*****@sergiolearns.com

The input dates are in the day/month/year format used in Spain and most of Europe; step 3 turns them into the international yyyy-mm-dd format, which sorts correctly as text.

Does the order of the steps matter?

A lot. Step 2 looks for one space before a punctuation mark; it works because step 1 has already turned “several spaces” into one. If you swap them, "Hi ," ends up as "Hi ,": step 2 removes only one of the three spaces, and step 1 then merges the other two into one, which stays in front of the comma. When you chain replaceAll calls, each step works on the result of the previous one, so think about the state each step leaves the text in.

What’s $1 in the replacement?

It means “whatever group 1 captured”. In step 2 we capture the punctuation mark, ([,.;:!?]), and put it back without the space before it. In step 3 we reorder the pieces of the date: $3-$2-$1 puts the year first. Notice that inside [ ] the dot and the question mark are literal: no need to escape them.

Wanting a real $ in the replacement text (a price, for example) and Java reading it as a group. Write it as \\$, or protect the whole replacement with Matcher.quoteReplacement(text).

Why does hideEmails pass a function to replaceAll?

Because the replacement depends on what was found: we want as many asterisks as letters in the name, and you can’t express that with $1. Since Java 9, Matcher.replaceAll accepts a function that receives each match (m) and returns the text that replaces it. Here: the first letter, as many * as the rest of the name, the at sign and the domain. sergio has 6 letters, so you get s plus 5 asterisks.

This is no longer just “find and replace”: it’s running Java code on every match. You’ll use it in the exercises to convert dates with leading zeros.

Try it yourself

Before moving on to the exercises, play with the programs:

  • In the validator, change the username rule so it accepts uppercase letters. Which part of the pattern do you need to change? And the rest of the code?
  • In the log reader, add a DEBUG level. Hint: only one part of the pattern and the switch change.
  • In the cleaner, add a step that capitalizes the first letter after a full stop. You’ll need the version of replaceAll that takes a function.

Watch it step by step in Python Tutor

Python Tutor runs your code line by line and draws the variables and objects at each step. Choose Java in the drop-down, paste this program and step through it with Next:

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

public class Tutor {
    public static void main(String[] args) {
        Matcher m = Pattern.compile("\\d+").matcher("3 cats and 12 fish");
        int total = 0;
        while (m.find()) {
            int n = Integer.parseInt(m.group());
            total += n;
        }
        System.out.println(total);
    }
}

Output:

15

What to watch:

  1. When m is created, notice that it’s an object on the heap, not a piece of text: the Matcher keeps the text and the position it has reached.
  2. On the first pass, find() finds 3 and n becomes 3. On the second, it finds 12: the same Matcher has moved forward, it didn’t start over.
  3. On the third call, find() returns false and the loop ends. If you called m.group() at that point, you’d get an IllegalStateException.
  4. Watch total change: 0 → 3 → 15. It’s the same accumulator pattern you used with arrays, except the numbers now come out of a text.

Summary

ProgramKey toolWhat to take away
Validatorstatic final Pattern + matches()Normalize before validating; the regex checks the shape, the code checks the meaning
Log readernamed groups + find() inside a groupmatches() for the whole line, find() to search inside; watch out for \r
CleanerreplaceAll with $1 and with a functionStep order matters; a function handles replacements that depend on the text

In the exercises you’ll test yourself on your own: number plates, dates and a log with IP addresses, plus three tricky snippets.

Next step: exercisesRegular expressions in Java — exercises with number plates, dates and logs (and the plate with vowels that slipped past me)

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 *