Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Groovy (Jenkins, Gradle and Spock)  Module 2 – Core syntax: values, strings, operators and control flow

Regular expressions the Groovy way

Find, match and extract text with Groovy's ~, =~ and ==~, named groups, replaceAll with closures, Matchers in Jenkins, and patterns safe for hostile input.

  • Intermediate
  • 10 minutes
  • Examples run with groovy 6.0.0, groovy5 5.1.3, groovyGradle 4.0.33, groovyJenkins 2.4.21, Java temurin-25.0.4+101.0.LTS, gradle 9.8.0, spock 2.4-groovy-5.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Match, find and extract text with ~/pattern/, =~ and ==~, and predict what each one returns
  • Extract capture groups by number and by name, and state which runtimes accept each form
  • Write replaceAll with a closure, and quote literal text with Pattern.quote and Matcher.quoteReplacement
  • Explain why a Matcher must not stay in a Pipeline variable, and keep only the strings it found
  • Protect a pattern that reads untrusted input with Groovy 6's RegexGuard, and catch a broken pattern at compile time

Before you start

What you’ll learn · Before you start

What you will learn

  • Match, find and extract text with ~/pattern/, =~ and ==~, and predict what each one returns
  • Extract capture groups by number and by name, and state which runtimes accept each form
  • Write replaceAll with a closure, and quote literal text with Pattern.quote and Matcher.quoteReplacement
  • Explain why a Matcher must not stay in a Pipeline variable, and keep only the strings it found
  • Protect a pattern that reads untrusted input with Groovy 6's RegexGuard, and catch a broken pattern at compile time

Before you start

On this page

('order-42 shipped, order-7 delayed' =~ /order-(\d+)/).collect { it[1] } gives [42, 7] in one line. Groovy turns Java’s regular-expression classes into three operators, and the same Matcher that makes this short is the object that stops a Jenkins Pipeline with NotSerializableException when it is kept in a variable. Named groups work through the operator only from Groovy 5, and Groovy 6 adds a guard for patterns that hostile input could keep busy for minutes.

You will find, match and extract with ~, =~ and ==~, read groups by number and by name, compute replacements with a closure, keep a Matcher out of Pipeline state, and guard and check patterns on the runtimes that support it.

Three operators make a Pattern, a Matcher and a boolean

The documentation describes three operators. ~/order-(\d+)/ compiles a java.util.regex.Pattern, usually written as a slashy string so backslashes need no escaping. text =~ /…/ gives a java.util.regex.Matcher, which finds matches anywhere in the text, and its truth is whether it finds one. text ==~ /…/ gives a Boolean, and the whole text must match. A capture group: A part of a regular expression in parentheses, such as (\d+), whose matched text the Matcher keeps. Group 0 is the whole match, so m[0][1] is group 1 of the first match., a part of the pattern in parentheses, keeps what it matched. Step through the find operator on a line of log:

Interactive · step by step What log =~ /order-(\d+)/ gives you Step through the find operator on the session's log line. Watch each match and its group appear.
What log =~ /order-(\d+)/ gives you, step 6 of 6. Showing The text, The pattern, A Matcher (true: found order-42), matcher[0], matcher[1]. In focus: The pattern.The textorder-42 shipped, order-7 …The patternorder-(\d+)A Matchertrue: found order-42matcher[0][order-42, 42]matcher[1][order-7, 7]

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The operator. log =~ /order-(\d+)/ does not return true or false. It returns a java.util.regex.Matcher that pairs the text with the pattern.
  2. Its truth. Asked for its truth, as in if (matcher), the Matcher looks for a first match. order-42 matches, so it is true, and the session printed yes.
  3. The first match. matcher[0] is a list, the whole match first and then each group. Group 1, (\d+), captured 42.
  4. The second match. matcher[1] is the second match, order-7, so matcher[1][1] is 7. matcher.size() counts the matches, 2.
  5. Every match. matcher.collect { it[1] } visits the matches in order and keeps group 1 of each, which gave the ids [42, 7].
  6. The full-match operator. ==~ gives a Boolean instead and needs the whole text to match, so 'v6.2' ==~ /v\d+\.\d+/ was true and 'v6.2.1' false.

basics.groovy GROOVY · basics.groovy
// The three regex operators, what each one gives, and how a Matcher hands out its matches.
def log = 'order-42 shipped, order-7 delayed'
def pattern = ~/order-(\d+)/
def matcher = log =~ /order-(\d+)/
println "~/order-(\\d+)/          ${pattern.getClass().name}"
println "log =~ /order-(\\d+)/    ${matcher.getClass().name}"
println "'v6.2' ==~ /v\\d+\\.\\d+/  ${'v6.2' ==~ /v\d+\.\d+/} (${('v6.2' ==~ /v\d+\.\d+/).getClass().simpleName})"
println "'v6.2.1' ==~ same      ${'v6.2.1' ==~ /v\d+\.\d+/}"
println "matcher found any?     ${matcher ? 'yes' : 'no'}"
println "matcher.size()         ${matcher.size()}"
println "matcher[0]             ${matcher[0]}"
println "matcher[1][1]          ${matcher[1][1]}"
println "ids                    ${matcher.collect { it[1] }}"
println "log.findAll(pattern)   ${log.findAll(pattern)}"
println "log.find(/\\d+/)        ${log.find(/\d+/)}"
def none = 'no orders today' =~ /order-(\d+)/
try {
    none[0]
} catch (IndexOutOfBoundsException e) {
    println "no match, none[0]      IndexOutOfBoundsException"
}
The three operators on four runtimes Python · basics_session.py
from session import jenkins_jar, run, use

# The regex operators on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy basics.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
    use(version)
    run(f"""
    groovy basics.groovy > on-{version}.txt
    diff on-6.0.0.txt on-{version}.txt && echo 'Groovy {version} printed the same lines'
    """)
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain basics.groovy > on-2.4.21.txt
diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
""")

Output

$ groovy basics.groovy | tee on-6.0.0.txt
~/order-(\d+)/          java.util.regex.Pattern
log =~ /order-(\d+)/    java.util.regex.Matcher
'v6.2' ==~ /v\d+\.\d+/  true (Boolean)
'v6.2.1' ==~ same      false
matcher found any?     yes
matcher.size()         2
matcher[0]             [order-42, 42]
matcher[1][1]          7
ids                    [42, 7]
log.findAll(pattern)   [order-42, order-7]
log.find(/\d+/)        42
no match, none[0]      IndexOutOfBoundsException
# The groovy command is Groovy 5.1.3 from here on.
$ groovy basics.groovy > on-5.1.3.txt
$ diff on-6.0.0.txt on-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines'
Groovy 5.1.3 printed the same lines
# The groovy command is Groovy 4.0.33 from here on.
$ groovy basics.groovy > on-4.0.33.txt
$ diff on-6.0.0.txt on-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines'
Groovy 4.0.33 printed the same lines
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain basics.groovy > on-2.4.21.txt
$ diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
Groovy 2.4.21 printed the same lines

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 basics_session.py

matcher[0] is a list, the whole match and then group 1, and matcher[1][1] is the second match’s group, 7. collect { it[1] } visits every match. findAll(pattern) returned the whole matches, and find(/\d+/) the first one, 42. 'v6.2.1' ==~ /v\d+\.\d+/ is false, because ==~ needs the whole text to match. Asking a Matcher with no match for none[0] threw IndexOutOfBoundsException, so test the Matcher’s truth, or use find, which returns null, before you index. All four runtimes printed the same lines. As a rule of thumb, use find for the first match, findAll for every match as a string, and the Matcher itself when you need the groups of each match.

Regex Tester Try a pattern on your own text and see each match and group before you put it in a script.

Key idea

=~ finds anywhere and gives a Matcher, whose truth is “found one”; ==~ asks whether the whole text matches; index a Matcher only after you know it matched.

Groups come out by number everywhere, and by name from Groovy 5

A named group: A capture group with a name, written (?<name>…) in Java's pattern syntax. Matcher.group('name') reads it on every runtime; m[0].name reads it through Groovy's match operator from Groovy 5., (?<job>[A-Z]+), is part of Java’s pattern syntax, so Matcher.group('job') works on every runtime. From Groovy 5, as its release notes describe, Groovy’s own indexing reads such a group by name too: the list that m[0] gives answers to m[0].job and to m[0]['job']. Groovy 6 adds findGroups, which its release notes describe as returning “the full match followed by each capture group as a List”, ready for multiple assignment:

groups.groovy and findGroups.groovy

GROOVY (groups.groovy) · groups.groovy

// Groups by number, by name, and with Groovy 6's findGroups.
def line = 'BUILD-1042 failed after 37 s'
def m = line =~ /(?<job>[A-Z]+)-(?<number>\d+)/
println "by number: ${m[0][1]} ${m[0][2]}"
m.reset()
m.find()
println "Matcher.group('number'): ${m.group('number')}"
println "by name: ${m[0].job} ${m[0].number}"

GROOVY (findGroups.groovy) · findGroups.groovy

def (all, major, minor, patch) = 'v6.2.1'.findGroups(/v(\d+)\.(\d+)\.(\d+)/)
println "findGroups: major $major, minor $minor, patch $patch"
Groups by number and by name, on four runtimes Python · groups_session.py
from session import jenkins_jar, run, use

# Groups by number and by name on the four runtimes (on 4.0.33 and 2.4.21 the lines printed before the error and the
# error's first line), then Groovy 6's findGroups, and on 5.1.3 only the first line of its error.
run("""
groovy groups.groovy
groovy findGroups.groovy
""")
use("5.1.3")
run("""
groovy groups.groovy
groovy findGroups.groovy 2>&1 | grep -m1 'Caught'
""")
use("4.0.33")
run("""
groovy groups.groovy 2>&1 | grep -v -e '^[[:space:]]*at ' -e '^groovy[.]lang[.]'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain groups.groovy 2>&1 | grep -v -e '^[[:space:]]*at ' -e '^groovy[.]lang[.]'
""")

Output

$ groovy groups.groovy
by number: BUILD 1042
Matcher.group('number'): 1042
by name: BUILD 1042
$ groovy findGroups.groovy
findGroups: major 6, minor 2, patch 1
# The groovy command is Groovy 5.1.3 from here on.
$ groovy groups.groovy
by number: BUILD 1042
Matcher.group('number'): 1042
by name: BUILD 1042
$ groovy findGroups.groovy 2>&1 | grep -m1 'Caught'
Caught: groovy.lang.MissingMethodException: No signature of method: findGroups for class: java.lang.String is applicable for argument types: (String) values: [v(\d+)\.(\d+)\.(\d+)]
# The groovy command is Groovy 4.0.33 from here on.
$ groovy groups.groovy 2>&1 | grep -v -e '^[[:space:]]*at ' -e '^groovy[.]lang[.]'
by number: BUILD 1042
Matcher.group('number'): 1042
Caught: groovy.lang.MissingPropertyException: Exception evaluating property 'job' for java.util.ArrayList, Reason: groovy.lang.MissingPropertyException: No such property: job for class: java.lang.String
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain groups.groovy 2>&1 | grep -v -e '^[[:space:]]*at ' -e '^groovy[.]lang[.]'
by number: BUILD 1042
Matcher.group('number'): 1042
Caught: groovy.lang.MissingPropertyException: Exception evaluating property 'job' for java.util.ArrayList, Reason: groovy.lang.MissingPropertyException: No such property: job for class: java.lang.String

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 groups_session.py

Every runtime read the groups by number and with Matcher.group('number'). m[0].job worked on 6.0.0 and 5.1.3 and threw MissingPropertyException on 4.0.33 and 2.4.21, where the list of a match has no names. findGroups exists only on 6.0.0: 5.1.3 answered No signature of method: findGroups. Note the two calls before group('number'): Java’s API says that group throws IllegalStateException when no match has been attempted, so groups.groovy resets the Matcher and calls find() first.

Reading a named group with m[0].name in Jenkins code

Jenkins’ Groovy 2.4.21 and Gradle’s 4.0.33 fail on m[0].job. Reset the Matcher and call find() and group('job'), as groups.groovy does, or read the group by number, which every runtime accepts.

Checkpoint

Check: The ~, =~ and ==~ operators · Groups by number and by name

  1. Question 1 of 2 Which expression is true only when the whole tag is a version like v6.2.1?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: tag ==~ /v\d+\.\d+\.\d+/

    Validate a whole value with ==~; find parts of a longer text with =~ or find. The documentation discourages ^ and $ with ==~, since the whole text must match anyway.

  2. Question 2 of 2 Which way of reading the named group job works on Groovy 2.4.21 and on 6.0.0?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: m.find(); m.group('job')

    Java's Matcher.group(String) has always read named groups; Groovy 5 added the shorter m[0].name, and Groovy 6 findGroups.

replaceAll takes a closure, and literal text needs quoting

replaceAll with a closure: A block of code in braces that can be stored in a variable, passed to a method and called later. In Groovy, braces after = always start a closure. More in “Groovy for Java developers: what changes” computes each replacement: the closure gets the whole match and its groups, and returns the text to put in. Two kinds of literal text need care. A user’s text used as a pattern is a pattern, so . matches any character and + repeats the one before it; Pattern.quote makes it match only itself. And in a replacement string, $1 means group 1, so a literal dollar needs Matcher.quoteReplacement:

replace.groovy GROOVY · replace.groovy
import java.util.regex.Matcher
import java.util.regex.Pattern

// replaceAll with a closure, then quoting text that must be taken literally.
println "snake to camel:        ${'build_api_server'.replaceAll(/_([a-z])/) { all, letter -> letter.toUpperCase() }}"
println "double each number:    ${'retries=3 timeout=30'.replaceAll(/\d+/) { it.toInteger() * 2 }}"

def userText = 'v1.2+build'
println "as a pattern, v1x22build matches: ${'v1x22build' ==~ userText}"
println "quoted:                ${Pattern.quote(userText)}"
println "quoted, v1x22build matches: ${'v1x22build' ==~ Pattern.quote(userText)}"
println "quoted, itself matches: ${userText ==~ Pattern.quote(userText)}"
try {
    'price: 5'.replaceAll(/\d+/, '$10')
} catch (IndexOutOfBoundsException e) {
    println "replacement '\$10':     IndexOutOfBoundsException: ${e.message}"
}
println "quoteReplacement:      ${'price: 5'.replaceAll(/\d+/, Matcher.quoteReplacement('$10'))}"
Replacements and quoting, on four runtimes Python · replace_session.py
from session import jenkins_jar, run, use

# Replacements and quoting on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy replace.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
    use(version)
    run(f"""
    groovy replace.groovy > on-{version}.txt
    diff on-6.0.0.txt on-{version}.txt && echo 'Groovy {version} printed the same lines'
    """)
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain replace.groovy > on-2.4.21.txt
diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
""")

Output

$ groovy replace.groovy | tee on-6.0.0.txt
snake to camel:        buildApiServer
double each number:    retries=6 timeout=60
as a pattern, v1x22build matches: true
quoted:                \Qv1.2+build\E
quoted, v1x22build matches: false
quoted, itself matches: true
replacement '$10':     IndexOutOfBoundsException: No group 1
quoteReplacement:      price: $10
# The groovy command is Groovy 5.1.3 from here on.
$ groovy replace.groovy > on-5.1.3.txt
$ diff on-6.0.0.txt on-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines'
Groovy 5.1.3 printed the same lines
# The groovy command is Groovy 4.0.33 from here on.
$ groovy replace.groovy > on-4.0.33.txt
$ diff on-6.0.0.txt on-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines'
Groovy 4.0.33 printed the same lines
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain replace.groovy > on-2.4.21.txt
$ diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
Groovy 2.4.21 printed the same lines

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 replace_session.py

The closure turned build_api_server into buildApiServer and doubled each number. Used as a pattern, the text v1.2+build matched v1x22build, because . took the x and 2+ took both 2s; Pattern.quote wrapped it in \Q…\E, and then it matched only itself. The replacement '$10' failed with IndexOutOfBoundsException: No group 1, because $1 names a group, and quoteReplacement made it a literal $10.

Lakh and crore grouping with one pattern

A lookahead can place the commas of Indian grouping: (\d)(?=(\d\d)+\d$) matches a digit followed by pairs of digits and one last digit, and replaceAll with '$1,' puts a comma after it. 12345678 became 1,23,45,678 and 1000000000 became 1,00,00,00,000, while 999 stayed as it was.

grouping.groovy GROOVY · grouping.groovy
// Indian digit grouping with one replaceAll: a comma after every digit followed by pairs of digits and one more digit.
['999', '1234', '100000', '12345678', '1000000000'].each { digits ->
    println "${digits.padRight(12)}${digits.replaceAll(/(\d)(?=(\d\d)+\d$)/, '$1,')}"
}
Digit grouping with one replaceAll, on four runtimes Python · grouping_session.py
from session import jenkins_jar, run, use

# Digit grouping with one replaceAll on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy grouping.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
    use(version)
    run(f"""
    groovy grouping.groovy > on-{version}.txt
    diff on-6.0.0.txt on-{version}.txt && echo 'Groovy {version} printed the same lines'
    """)
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain grouping.groovy > on-2.4.21.txt
diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
""")

Output

$ groovy grouping.groovy | tee on-6.0.0.txt
999         999
1234        1,234
100000      1,00,000
12345678    1,23,45,678
1000000000  1,00,00,00,000
# The groovy command is Groovy 5.1.3 from here on.
$ groovy grouping.groovy > on-5.1.3.txt
$ diff on-6.0.0.txt on-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines'
Groovy 5.1.3 printed the same lines
# The groovy command is Groovy 4.0.33 from here on.
$ groovy grouping.groovy > on-4.0.33.txt
$ diff on-6.0.0.txt on-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines'
Groovy 4.0.33 printed the same lines
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain grouping.groovy > on-2.4.21.txt
$ diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
Groovy 2.4.21 printed the same lines

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 grouping_session.py

A Matcher cannot be saved, and Jenkins saves Pipeline variables

A running Jenkins Pipeline is saved as it goes. The Pipeline best practices page says that CPS transformation: The continuation-passing style rewrite that the Jenkins Pipeline plugin applies to Pipeline Groovy, so that Jenkins can save a running build's program state to disk and resume it after a restart. More in “Groovy in 2026: one language, three runtimes” “relies on being able to serialize the pipeline’s current state”, that local variables are part of that state, and that a variable holding an object that cannot be serialized causes a NotSerializableException. A Matcher is such an object. Step through what Jenkins has to save, then watch Java’s own serialization refuse the Matcher without a Jenkins controller:

Interactive · message by message A Matcher in a Pipeline variable Step through a Pipeline that keeps a Matcher in a variable while a step runs. Watch what Jenkins has to save.
A Matcher in a Pipeline variable, step 5 of 5. Messages so far: Pipeline code to Jenkins: locals, m included; Jenkins to Saved state: version: 6.2.1.Pipeline codedef version = … [0][1]Jenkinssaves the running stateSaved stateon the controllerlocals, m includedversion: 6.2.1

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A Matcher in a variable. The Pipeline reads a version from a step's output with =~ and keeps the Matcher in the local variable m.
  2. Jenkins saves the state. The Jenkins documentation says Pipeline's CPS relies on serializing the running state, and that local variables are part of that state, so m has to be saved with the rest.
  3. The Matcher cannot be saved. java.util.regex.Matcher is not Serializable, so saving fails with NotSerializableException, the error saving.groovy printed for the Matcher.
  4. Keep only the String. Read the value out at once, def version = (output =~ /…/)[0][1], so the variable holds a String. The String and a Pattern saved without trouble.
  5. Saved. Now every local can be saved, and the state reaches the controller's disk. For longer regex work, the documentation's tool is a method marked @NonCPS.

saving.groovy GROOVY · saving.groovy
// What Java serialization does with each value: a Pipeline is saved only when every value it holds can be serialized.
def save(String name, Object value) {
    try {
        new ObjectOutputStream(new ByteArrayOutputStream()).withCloseable { it.writeObject(value) }
        println "${name.padRight(22)}saved"
    } catch (NotSerializableException e) {
        println "${name.padRight(22)}NotSerializableException: ${e.message}"
    }
}

def output = 'Built image app:6.2.1 in 41 s'
def matcher = output =~ /app:(\d+\.\d+\.\d+)/
save 'the Matcher', matcher
save 'the String it found', matcher[0][1]
save 'the Pattern', ~/app:(\d+\.\d+\.\d+)/
Saving a Matcher, a String and a Pattern, on four runtimes Python · saving_session.py
from session import jenkins_jar, run, use

# Saving a Matcher, a String and a Pattern on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy saving.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
    use(version)
    run(f"""
    groovy saving.groovy > on-{version}.txt
    diff on-6.0.0.txt on-{version}.txt && echo 'Groovy {version} printed the same lines'
    """)
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain saving.groovy > on-2.4.21.txt
diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
""")

Output

$ groovy saving.groovy | tee on-6.0.0.txt
the Matcher           NotSerializableException: java.util.regex.Matcher
the String it found   saved
the Pattern           saved
# The groovy command is Groovy 5.1.3 from here on.
$ groovy saving.groovy > on-5.1.3.txt
$ diff on-6.0.0.txt on-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines'
Groovy 5.1.3 printed the same lines
# The groovy command is Groovy 4.0.33 from here on.
$ groovy saving.groovy > on-4.0.33.txt
$ diff on-6.0.0.txt on-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines'
Groovy 4.0.33 printed the same lines
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain saving.groovy > on-2.4.21.txt
$ diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
Groovy 2.4.21 printed the same lines

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 saving_session.py

The Matcher failed with NotSerializableException: java.util.regex.Matcher on every runtime, while the String it found and the Pattern were saved. In a Jenkinsfile, read what you need out of the Matcher in the same expression, as in def version = (output =~ /app:(\d+\.\d+\.\d+)/)[0][1], so the variable holds a String. For longer text processing, the best practices page names @NonCPS, which disables the CPS transformation for one method; such a method may use Matchers freely, but it may not call Pipeline steps such as sh.

Key idea

Keep strings, not Matchers, in Pipeline variables: extract the groups at once, or do the regex work in a @NonCPS method that returns plain values.

Checkpoint

Check: Replacements and quoting · Matchers in Pipeline variables

  1. Question 1 of 2 A script removes a user-supplied branch name such as feature/a+b from a text with replaceAll. Which call removes exactly that text?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: text.replaceAll(Pattern.quote(branch), '')

    Text used as a pattern is a pattern. Quote it with Pattern.quote, or use replace(), which takes literal text.

  2. Question 2 of 2 Which line keeps a Pipeline safe to save while it extracts the version from output?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: def version = (output =~ /app:(\S+)/)[0][1]

    A Matcher cannot be serialized, and Jenkins saves Pipeline locals. Extract strings at once, or put the regex work in a @NonCPS method that returns plain values.

Patterns that read untrusted text need a time limit and a compile-time check

Some patterns backtrack for an extremely long time on input built to defeat them, a problem known as catastrophic backtracking: A regular expression trying so many ways to match a crafted input that one evaluation runs for minutes or longer. Java has no timeout for it; Groovy 6's RegexGuard stops it with a RegexTimeoutException.; the documentation calls the attack a regular expression denial of service and notes that “Java’s regex engine has no native timeout”. Groovy 6 adds groovy.util.regex.RegexGuard, which stops an evaluation with RegexTimeoutException once its time limit passes. A broken pattern is a different risk: it compiles and fails only when it runs, unless the RegexChecker type-checking extension, bundled since Groovy 4, checks it when the code compiles:

guard.groovy and checked.groovy

GROOVY (guard.groovy) · guard.groovy

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

// A pattern that backtracks for a very long time on hostile input, run under Groovy 6's guard.
def hostile = '1,' * 40
long start = System.nanoTime()
try {
    RegexGuard.guard(200) { hostile ==~ /(.*,){16}X/ }
    println 'finished'
} catch (RegexTimeoutException e) {
    long ms = (System.nanoTime() - start).intdiv(1_000_000)
    println "stopped by the guard: ${e.getClass().simpleName}, in under a second: ${ms < 1000}"
}
println "an ordinary match under the guard: ${RegexGuard.guard(200) { 'v6.2.1' ==~ /v\d+\.\d+\.\d+/ }}"

GROOVY (checked.groovy) · checked.groovy

import groovy.transform.TypeChecked

@TypeChecked(extensions = 'groovy.typecheckers.RegexChecker')
boolean isTag(String name) {
    name ==~ /v(\d+\.\d+/
}

println isTag('v6.2')
RegexGuard and RegexChecker on four runtimes Python · guard_session.py
from session import jenkins_jar, run, use

# Groovy 6's RegexGuard, then the RegexChecker extension on each runtime. A file that does not compile shows only the
# line that names its error.
run("""
groovy guard.groovy
groovy checked.groovy 2>&1 | grep -o 'Bad regex: .*'
""")
use("5.1.3")
run("""
groovy guard.groovy 2>&1 | grep -m1 -o 'unable to resolve class .*'
groovy checked.groovy 2>&1 | grep -o 'Bad regex: .*'
""")
use("4.0.33")
run("""
groovy checked.groovy 2>&1 | grep -o 'Bad regex: .*'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain checked.groovy 2>&1 | grep -o "Static type checking extension .*"
""")

Output

$ groovy guard.groovy
stopped by the guard: RegexTimeoutException, in under a second: true
an ordinary match under the guard: true
$ groovy checked.groovy 2>&1 | grep -o 'Bad regex: .*'
Bad regex: Unclosed group near index 10
# The groovy command is Groovy 5.1.3 from here on.
$ groovy guard.groovy 2>&1 | grep -m1 -o 'unable to resolve class .*'
unable to resolve class groovy.util.regex.RegexGuard
$ groovy checked.groovy 2>&1 | grep -o 'Bad regex: .*'
Bad regex: Unclosed group near index 10
# The groovy command is Groovy 4.0.33 from here on.
$ groovy checked.groovy 2>&1 | grep -o 'Bad regex: .*'
Bad regex: Unclosed group near index 10
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain checked.groovy 2>&1 | grep -o "Static type checking extension .*"
Static type checking extension 'groovy.typecheckers.RegexChecker' was not found on the classpath.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 guard_session.py

The guard stopped the documentation’s runaway pattern in under a second and let an ordinary match through; on 5.1.3 the class does not exist. RegexChecker refused /v(\d+\.\d+/ with Bad regex: Unclosed group near index 10 on 6.0.0, 5.1.3 and 4.0.33, before anything ran. Jenkins’ 2.4.21 has no such extension, so there a broken pattern is found only by a test that runs it.

Timeline · 4 events in order Regular expressions in Groovy, release by release Walk through the release lines in order. Each event says what a Jenkinsfile on Groovy 2.4.21 cannot use.
  1. 2.4

    The operators: ~, =~ and ==~ Jenkins

    Pattern literals, the find and match operators, groups by number, findAll and replaceAll with a closure all work on Groovy 2.4.21, the Groovy of Jenkins.

  2. 4.0

    A compile-time regex checker incubating

    Groovy 4 bundled the optional groovy-typecheckers module with RegexChecker, which reports a broken pattern when the code compiles. On 2.4.21 the extension was not found.

  3. 5.0

    Groups by name through the match operator new

    Groovy 5 lets m[0].name and m[0]['name'] read a named group. On 4.0.33 and 2.4.21 the same line threw MissingPropertyException.

  4. 6.0

    findGroups, RegexGuard, and a stable checker new

    findGroups returns the match and its groups as a list, RegexGuard and @SafeRegex stop runaway patterns with a time limit, and RegexChecker leaves incubation and also checks String#matches.

The timeline has 4 events, from 2.4 to 6.0.

Patterns that read text you do not control

For log lines, parameters or request data, keep patterns simple and anchored, run them through RegexGuard or @SafeRegex on Groovy 6, and limit the length of the input on older runtimes, where no guard exists.

Checkpoint

Check: Guarding and checking patterns

  1. One question A shared helper must check build-parameter text with a pattern and runs on Groovy 2.4.21 and 6.0.0. Which protection is available on both?

    Choose one answer.

    Show the answer: One question

    Answer: Limiting the length of the input before matching

    On Groovy 6 use RegexGuard or @SafeRegex for untrusted text; where older runtimes must run the code, keep the pattern simple, bound the input, and test the pattern with hostile examples.

Practise: regular expressions

Practise the regex operators, groups, replacements, Matchers and guards. The session puts the idea you know least first and gives each answer its reason from the sessions:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 What does text =~ /\d+/ return?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: A java.util.regex.Matcher

    ~ builds a Pattern, =~ a Matcher, ==~ a Boolean.

  2. Question 2 of 15 if (line =~ /ERROR/) runs for which line?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: '12:01 ERROR disk full'

    Groovy gives a Matcher its own truth, so =~ reads naturally in conditions; add (?i) to the pattern to ignore case.

  3. Question 3 of 15 A script reads (line =~ /id=(\d+)/)[0][1] for every line of a file, and some lines have no id. What happens there, and what do you write instead?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: IndexOutOfBoundsException on those lines; test the Matcher first, or use line.find(/id=(\d+)/) { all, id -> id }, which gives null

    Check the Matcher's truth before you index it, or use find with a closure, which returns null when nothing matches.

  4. Question 4 of 15 In m[0][1], what are 0 and 1?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: The first match, and its first capture group

    matcher[0] was [order-42, 42] in the session: the whole match, then group 1.

  5. Question 5 of 15 A helper reads m[0].number for a named group. Where does it work?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: On Groovy 5.1.3 and 6.0.0, and not on 4.0.33 or 2.4.21

    Gradle 9.8's build scripts run on 4.0.33 and Jenkins on 2.4.21, so code for them reads named groups with Matcher.group(name) or groups by number.

  6. Question 6 of 15 What does this print on Groovy 6.0.0?

    Read the code, then choose one answer.

    def (all, major, minor, patch, pre) = '6.0.0'.findGroups(/(\d+)\.(\d+)\.(\d+)(?:-(.+))?/)
    println "$major $minor $patch $pre"
    Show the answer: Question 6 of 15

    Answer: 6 0 0 null

    findGroups gives [whole match, group 1, …]; an optional group that did not take part is null, so the same destructuring works with and without a qualifier.

  7. Question 7 of 15 What does this print?

    Read the code, then choose one answer.

    println 'retries=3 timeout=30'.replaceAll(/\d+/) { it.toInteger() * 2 }
    Show the answer: Question 7 of 15

    Answer: retries=6 timeout=60

    With a closure, replaceAll computes each replacement from the match; with no groups the closure gets the matched text.

  8. Question 8 of 15 'price: 5'.replaceAll(/\d+/, '$10') throws IndexOutOfBoundsException. Why, and what is the fix?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: $1 in a replacement names group 1, which the pattern does not have; wrap the text in Matcher.quoteReplacement

    In a replacement, $ starts a group reference and \ escapes; Matcher.quoteReplacement makes the text literal.

  9. Question 9 of 15 Why did the text v1.2+build, used as a pattern, match v1x22build?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: The dot matches any character and 2+ matches one or more 2s

    Characters such as . + * ? ( ) [ ] { } | ^ $ and \ have a meaning in a pattern. Quote text that must be taken literally.

  10. Question 10 of 15 Why does a Matcher in a Pipeline variable cause NotSerializableException?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: Jenkins serializes Pipeline locals to save the running state, and Matcher is not Serializable

    Java serialization refuses objects that are not Serializable. The String a Matcher found and the Pattern are both serializable.

  11. Question 11 of 15 A helper marked @NonCPS parses a long log with several Matchers. What must it not do?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: Call Pipeline steps such as sh or sleep

    @NonCPS switches off the CPS transformation for one method, so it can use non-serializable objects, but it cannot run steps. Return strings or lists of strings to the Pipeline.

  12. Question 12 of 15 A Pipeline reads three values from one log with a Matcher and then runs a deploy step. Which design keeps it safe?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: A @NonCPS method that returns a map of the three Strings, called before the step

    Separate regex work from steps: a @NonCPS helper returns plain data, and the Pipeline code that runs steps keeps only that data.

  13. Question 13 of 15 What does Groovy 6's RegexGuard do?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Stops a regex evaluation with RegexTimeoutException once its time limit passes

    Java's regex engine has no timeout of its own; RegexGuard adds one for evaluations made through Groovy's operators and extension methods.

  14. Question 14 of 15 Where does @TypeChecked(extensions = 'groovy.typecheckers.RegexChecker') report the pattern /v(\d+\.\d+/?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: At compile time on Groovy 4.0.33, 5.1.3 and 6.0.0

    RegexChecker arrived as an incubating checker in Groovy 4 and left incubation in Groovy 6, where it also checks String#matches.

  15. Question 15 of 15 A Groovy 6 service validates file names from uploads with a regex. Which combination fits best?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: A simple anchored pattern, a length limit, and RegexGuard or @SafeRegex around the match

    Keep patterns simple, bound the input and add a time limit where the runtime offers one; test with hostile examples such as long repetitions.

Interview questions

Warm-up (fresher): What is the difference between =~ and ==~ in Groovy?

text =~ /pattern/ is the find operator: it returns a java.util.regex.Matcher that searches for the pattern anywhere in the text. Its truth is whether it finds a match, so if (line =~ /ERROR/) picks every line that contains ERROR, and indexing it gives the matches and their groups, as matcher[0][1] for group 1 of the first match. text ==~ /pattern/ is the match operator: it returns a Boolean, and it is true only when the whole text matches, so 'v6.2' ==~ /v\d+\.\d+/ is true and 'v6.2.1' ==~ /v\d+\.\d+/ is false. Use ==~ to validate a whole value, such as a version or an id, and =~ to find and extract parts of a longer text.

Two longer interview questions of this module start from this lesson:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A scripted Pipeline does def m = readFile('build.log') =~ /app:(\S+)/, then runs sh 'make deploy', and afterwards uses m[0][1]. The build fails with java.io.NotSerializableException: java.util.regex.Matcher. Explain the cause and give two fixes.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Write the approach for a helper that returns the highest release tag from a list such as v1.9.0, v1.10.0, v2.0.0-rc1 and release-1.2, ignoring pre-releases and names that are not versions. Which pattern do you use, how do you compare, and which cases do you test?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • ~/…/ compiles a Pattern, =~ gives a Matcher that finds matches anywhere and is true when it finds one, and ==~ is a Boolean that needs the whole text to match.
  • Groups come out by number on every runtime and through Matcher.group(name); m[0].name needs Groovy 5, and findGroups needs Groovy 6.
  • replaceAll with a closure computes each replacement, Pattern.quote makes user text match only itself, and Matcher.quoteReplacement keeps a $ literal.
  • A Matcher cannot be serialized, so a Jenkins Pipeline must keep only the strings it extracts, or do the regex work in a @NonCPS method.
  • Groovy 6’s RegexGuard stops runaway patterns with a time limit, and RegexChecker, from Groovy 4 on, rejects a broken pattern when the code compiles.

Terms in this lesson

capture group
A part of a regular expression in parentheses, such as (\d+), whose matched text the Matcher keeps. Group 0 is the whole match, so m[0][1] is group 1 of the first match.
catastrophic backtracking
A regular expression trying so many ways to match a crafted input that one evaluation runs for minutes or longer. Java has no timeout for it; Groovy 6's RegexGuard stops it with a RegexTimeoutException.
named group
A capture group with a name, written (?<name>…) in Java's pattern syntax. Matcher.group('name') reads it on every runtime; m[0].name reads it through Groovy's match operator from Groovy 5.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.