Regular Expressions

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Groovy 6.0.0 (released 2026-09-22), run on JDK 26, and checked on 2026-10-14. Groovy has no regex engine of its own: it sits on java.util.regex, so every pattern syntax rule, flag and group feature described on Java: Regular Expressions applies unchanged. What Groovy adds is syntax (three operators and slashy strings) and GDK methods that remove most of the Pattern/Matcher boilerplate. The examples use the greenhouse scenario: sensor log lines such as zone=A temp=21 hum=64.

The three regex operators

Operator Result Meaning

~/regex/ (pattern)

java.util.regex.Pattern

compiles the regex

text =~ /regex/ (find)

java.util.regex.Matcher

a matcher over text, ready to find()

text ==~ /regex/ (match)

boolean

true only if the whole of text matches

The pattern operator is a prefix operator that turns any string into a Pattern. It is usually written with a slashy string, but works with single quotes, double quotes, GStrings and dollar-slashy strings too.

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

def p = ~/temp=(\d+)/
assert p instanceof Pattern

def reading = 'zone=A temp=21 hum=64'

def m = reading =~ /temp=(\d+)/
assert m instanceof Matcher
assert m                         // truthy: find() succeeded
assert !('no data' =~ /temp/)    // falsy when nothing is found

// find vs match
assert !(reading ==~ /temp=\d+/)             // must match the whole string
assert reading ==~ /.*temp=\d+.*/
assert (reading ==~ /.*/) instanceof Boolean

// the same pattern from other string forms
def prefix = 'temp'
assert (~"${prefix}=\\d+").matcher(reading).find()
assert (~$/temp=\d+/$).matcher(reading).find()
assert (~'temp=\\d+').matcher(reading).find()

Run with Groovy 6.0.0 on JDK 26, every assertion holds. Source: Groovy documentation: Regular expression operators.

Find versus match

A Matcher coerces to a boolean through the Groovy truth by calling find(), which is why if (text =~ /x/) reads like Perl. The match operator ==~ is stricter: it calls matches(), so the pattern has to cover the entire input and explicit ^ and $ anchors are redundant. Use ==~ for validation ("is this string a zone code?") and =~ for searching ("is there a temperature somewhere in this line?").

A Matcher also behaves like a collection of matches. size() counts them, and subscripting returns a match: a plain String when the regex has no groups, or a list [whole match, group 1, group 2, ...] when it does.

Groups and named groups

def log = 'zone=A temp=21 hum=64; zone=B temp=27 hum=58'

def m = log =~ /zone=(\w) temp=(\d+) hum=(\d+)/
assert m.size() == 2
assert m[0] == ['zone=A temp=21 hum=64', 'A', '21', '64']
assert m[1][2] == '27'
assert m.hasGroup() && m.groupCount() == 3

// iterating: the closure receives the whole match followed by each group
def temps = []
m.each { all, zone, temp, hum -> temps << "$zone:$temp" }
assert temps == ['A:21', 'B:27']

// manual iteration with find() and named groups
def m2 = log =~ /zone=(?<zone>\w) temp=(?<temp>\d+)/
def seen = []
while (m2.find()) { seen << m2.group('zone') + '=' + m2.group('temp') }
assert seen == ['A=21', 'B=27']

Run with Groovy 6.0.0, every assertion holds. Named groups are plain Java (?<name>...) syntax, read with Matcher.group(String) after a successful find(). Note that the matcher is stateful: m[0] and each iterate from the start, while find() advances an internal cursor, so do not mix both styles on the same matcher without calling reset(). Source: Groovy documentation: Find operator.

GDK helpers

The GDK adds methods to CharSequence that accept a regex (as a String or a Pattern) and optionally a closure receiving the match and its groups.

def log = 'zone=A temp=21 hum=64; zone=B temp=27 hum=58'

// findAll with a closure: one result per match
def hot = log.findAll(/temp=(\d+)/) { all, t -> t as int }
assert hot == [21, 27]

// find returns the first match (the closure result is returned as a String)
assert log.find(/temp=(\d+)/) == 'temp=21'
assert log.find(/temp=(\d+)/) { all, t -> t.toInteger() * 2 } == '42'

// eachMatch
def zones = []
log.eachMatch(/zone=(\w)/) { all, z -> zones << z }
assert zones == ['A', 'B']

// replaceAll with a closure: compute the replacement
assert log.replaceAll(/temp=(\d+)/) { all, t -> "temp=${(t as int) * 9 / 5 + 32}F" } ==
        'zone=A temp=69.8F hum=64; zone=B temp=80.6F hum=58'
assert 'x1y2'.replaceAll(/\d/) { d -> (d as int) * 2 } == 'x2y4'

// replaceFirst and back-references in a replacement string
assert 'a-b-c'.replaceFirst(/-/, '+') == 'a+b-c'
assert 'zone=A'.replaceAll(/(\w+)=(\w+)/, '$2=$1') == 'A=zone'

// flags are inline Java flags
assert 'TEMP=3' =~ /(?i)temp=\d/

Run with Groovy 6.0.0, every assertion holds. When the regex has groups, the closure parameters are the whole match followed by one parameter per group; with no groups it receives only the match. replaceAll(Pattern, Closure) and the string form share this rule. Two practical notes: the replacement string form ('$2=$1') is Java’s, so keep it in single quotes to avoid GString interpolation, and prefer the String.replaceAll(regex, Closure) form over calling replaceAll on a Matcher when you want the groups as arguments. Source: Groovy documentation: Working with collections and the GDK.

findGroups and findAllGroups (Groovy 6.0)

Extracting groups used to mean indexing a Matcher and guarding against a non-match. Groovy 6.0 adds findGroups, which returns the full match followed by each capture group as a list, and findAllGroups, which does so for every match. The list pairs naturally with multi-assignment. An optional group that did not take part in the match yields null.

def semver = /(\d+)\.(\d+)\.(\d+)(?:-(.+))?/
def (_, major, minor, patch, qualifier) = '6.0.0-beta-2'.findGroups(semver)
assert [major, minor, patch, qualifier] == ['6', '0', '0', 'beta-2']
def (_all, ma, mi, pa, q) = '4.0.28'.findGroups(semver)
assert q == null

println 'no match gives: ' + 'no digits'.findGroups(semver).inspect()

def all = 'zone=A temp=21; zone=B temp=27'.findAllGroups(/zone=(\w) temp=(\d+)/)
println all.inspect()
assert all == [['zone=A temp=21', 'A', '21'], ['zone=B temp=27', 'B', '27']]
no match gives: []
[['zone=A temp=21', 'A', '21'], ['zone=B temp=27', 'B', '27']]

Output from Groovy 6.0.0 on JDK 26: a non-matching input gives an empty list rather than null. Source: Groovy 6.0 release notes: Extension method additions and improvements.

Slashy strings for patterns

The Groovy documentation recommends slashy strings for every regex operator, because backslashes do not need doubling. In a slashy string only the forward slash itself needs escaping; the dollar-slashy form removes even that.

assert '2026-10-14' ==~ /\d{4}-\d{2}-\d{2}/
assert 'C:\\dir' ==~ /C:\\dir/       // one regex backslash escapes one literal backslash
assert 'a/b' ==~ /a\/b/              // a slash must be escaped in a slashy string
assert 'a/b' ==~ $/a/b/$             // ... but not in a dollar-slashy string
assert 'a1b22c333'.split(/\d+/) as List == ['a', 'b', 'c']
assert ['t1', 'x', 't22'].grep(~/t\d+/) == ['t1', 't22']
assert 'a.b' ==~ /${java.util.regex.Pattern.quote('a.b')}/     // interpolation of a quoted literal
assert !('axb' ==~ /${java.util.regex.Pattern.quote('a.b')}/)

Run with Groovy 6.0.0, every assertion holds. Slashy strings support ${...} interpolation, so a pattern can embed a variable; wrap untrusted text in Pattern.quote first, otherwise its characters are interpreted as regex syntax. An empty slashy string // is a comment, not a string, so write '' or ~'' instead. See Strings & GStrings for the other literal forms.

Patterns in switch and grep

A Pattern implements isCase, which is true when the whole subject matches. That makes a regex usable as a switch case label and as a grep filter. See Control Structures.

switch ('hum=64') {
    case ~/hum=\d+/: println 'switch matched'; break
    default: println 'no'
}
switch matched

Guarding against runaway regexes

Java’s regex engine has no built-in timeout, so a pattern with catastrophic backtracking (nested quantifiers such as (.*,){16}X) can pin a thread for practically unlimited time on hostile input: a Regular Expression Denial of Service (ReDoS). Groovy 6.0 adds a regex timeout facility with two entry points.

RegexGuard

groovy.util.regex.RegexGuard throws groovy.util.regex.RegexTimeoutException once a deadline passes. Wrapping a closure with guard(millis) { ... } bounds every regex evaluation reached through Groovy’s regex operators and CharSequence GDK methods on the same thread during the closure. Wrapping data with guard(input, millis) gives that text a single total budget shared by every regex evaluated against it.

import groovy.util.regex.RegexGuard
import groovy.util.regex.RegexTimeoutException

def groups = RegexGuard.guard(200) {
    '6.0.0-beta-2'.findGroups(/(\d+)\.(\d+)\.(\d+)(?:-(.+))?/)
}
assert groups == ['6.0.0-beta-2', '6', '0', '0', 'beta-2']

try {
    RegexGuard.guard(200) { ('1,' * 40) ==~ /(.*,){16}X/ }
    assert false, 'should have timed out'
} catch (RegexTimeoutException e) { println 'guard timed out: ' + e.message }

def untrusted = RegexGuard.guard('1,' * 40, 200)
try {
    untrusted.findAll(~/(.*,){16}X/)
    assert false
} catch (RegexTimeoutException e) { println 'data guard timed out' }
guard timed out: regex evaluation exceeded timeout of 200 ms
data guard timed out

Output from Groovy 6.0.0 on JDK 26. Well-behaved evaluations are unaffected. The guard checks the deadline while the engine reads input characters, so direct JDK calls bypass it: on a String receiver, matches(String), replaceAll(String, String), replaceFirst(String, String) and split(String) dispatch to java.lang.String itself, while their Pattern-argument variants are Groovy extension methods and are covered. The guarded equivalents of the operators are RegexGuard.matches(pattern, input, millis) and RegexGuard.matcher(pattern, input, millis). Source: Groovy documentation: Guarding against runaway regular expressions.

@SafeRegex

The @SafeRegex AST transformation (Groovy 6.0) rewrites the =~ and ==~ operators inside the annotated scope into guarded calls. millis defaults to 1000. Placed on a class, it reaches nested and anonymous inner classes as well as the class’s own members.

import groovy.transform.SafeRegex
import groovy.util.regex.RegexTimeoutException

@SafeRegex(millis = 250)
boolean looksLikeCsv(String input) {
    input ==~ /(.*,){16}X/      // pathological on purpose
}

assert looksLikeCsv('1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,X')
long start = System.nanoTime()
try {
    looksLikeCsv('1,' * 40)
    assert false, 'expected a timeout'
} catch (RegexTimeoutException e) {
    long ms = (System.nanoTime() - start).intdiv(1_000_000)
    println "${e.class.simpleName}: ${e.message}"
    assert ms < 5000
}
RegexTimeoutException: regex evaluation exceeded timeout of 250 ms

Output from Groovy 6.0.0 on JDK 26. Matching results are otherwise unchanged. The first example in the release notes uses (a+)+$, but on current JDKs the regex engine optimises that particular shape, so it did not time out when tried here; the (.*,){16}X shape above is reliably slow. Applying @SafeRegex to a whole class also worked when tried here. Source: Groovy 6.0 release notes: Regex Timeout Facility.

A timeout is a runtime safety net. To catch invalid regex literals at compile time, the RegexChecker type checker validates literal patterns under @TypeChecked; see Built-in Type Checkers.

Where older material differs

Books written for Groovy 1.x describe the same three operators and the same Matcher indexing, and those parts are still accurate. What they cannot cover is findGroups/findAllGroups and the timeout facility (both new in 6.0), and the GDK closure helpers are usually shown only in their String form. Treat any advice on "avoiding ReDoS by careful pattern design" as incomplete today: design the pattern carefully and bound untrusted evaluation with RegexGuard or @SafeRegex.