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 |
|---|---|---|
|
|
compiles the regex |
|
|
a matcher over |
|
|
|
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.
References
-
Groovy documentation: Guarding against runaway regular expressions
-
Groovy 6.0 release notes (Regex Timeout Facility, extension method additions)
-
GDK: CharSequence methods (find, findAll, findGroups, findAllGroups, eachMatch, replaceAll)