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

Strings, GStrings and multi-line text

The six Groovy string literals, when a GString reads ${} placeholders, why it misses as a map key, the GDK string methods, and sh steps in Jenkins.

  • Beginner
  • 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

  • Choose among single-quoted, double-quoted, triple-quoted, slashy and dollar-slashy strings for a given text
  • Predict when a GString reads its placeholders, and delay the reading with ${-> }
  • Explain why a GString key misses a String lookup in a map, set or list, and fix it with toString()
  • Use the GDK string methods for padding, splitting, repeating and stripping indentation
  • Recognise Groovy interpolation in a Jenkins sh step and keep parameters and secrets out of it

Before you start

What you’ll learn · Before you start

What you will learn

  • Choose among single-quoted, double-quoted, triple-quoted, slashy and dollar-slashy strings for a given text
  • Predict when a GString reads its placeholders, and delay the reading with ${-> }
  • Explain why a GString key misses a String lookup in a map, set or list, and fix it with toString()
  • Use the GDK string methods for padding, splitting, repeating and stripping indentation
  • Recognise Groovy interpolation in a Jenkins sh step and keep parameters and secrets out of it

Before you start

On this page

"api-${env}" == 'api-prod' is true in Groovy when env is 'prod', yet a map whose key was written "api-${env}" returns null for tags['api-prod']. The double-quoted string is a GString, a different class from String, and that difference decides map lookups, the moment a value is read and what a Jenkins sh step runs.

Groovy has six ways to write a string literal, and four of them fill in placeholders. You will pick the right one for paths, patterns and multi-line text, predict when ${} is evaluated, make GString keys find their entries, use the string methods that Groovy adds to Java, and keep build parameters out of shell commands.

Six string literals, and four of them fill in placeholders

Single quotes make a plain java.lang.String. Double quotes make a GString: groovy.lang.GString, the class of a double-quoted string that holds a placeholder such as ${name}. It keeps its text and its values apart and joins them when it is used as text, so it equals a String under == but not under equals. as soon as the text holds a placeholder such as ${name}; filling it in is called interpolation: Filling a placeholder such as ${name} in a string with the value of the expression inside it. Groovy interpolates double-quoted, triple-double-quoted, slashy and dollar-slashy strings, never single-quoted ones.. Triple quotes do the same across several lines, and the two slashy forms exist for text full of backslashes and slashes. The documentation sums them up in a table, which this one follows:

Literal Fills in ${} Spans lines Escape character
'…' single-quoted no no \
'''…''' triple-single-quoted no yes \
"…" double-quoted yes no \
"""…""" triple-double-quoted yes yes \
/…/ slashy yes yes \, only before /
$/…/$ dollar-slashy yes yes $

The program prints what each form holds and its class, then two strings that need no escaping at all:

quotes.groovy GROOVY · quotes.groovy
// The six string literals of Groovy: what each one holds, and its class.
def name = 'Asha'
def forms = [
    "'...'"     : 'Hi ${name}',
    '"..."'     : "Hi ${name}",
    "'''...'''" : '''Hi ${name}''',
    '"""..."""' : """Hi ${name}""",
    '/.../'     : /Hi ${name}/,
    '$/.../$'   : $/Hi ${name}/$,
]
forms.each { form, text ->
    println "${form.padRight(12)}${text.toString().padRight(12)}${text.getClass().simpleName}"
}
def plain = "Hi there"
println "\"Hi there\" has no placeholder, so it is a ${plain.getClass().simpleName}"
println(/In a slashy string a Windows path keeps its backslashes: C:\Users\asha/)
println($/A dollar-slashy string keeps / and \d+, and writes $$ for a dollar sign/$)
The six literals on four runtimes Python · quotes_session.py
from session import jenkins_jar, run, use

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

Output

$ groovy quotes.groovy | tee on-6.0.0.txt
'...'       Hi ${name}  String
"..."       Hi Asha     GStringImpl
'''...'''   Hi ${name}  String
"""..."""   Hi Asha     GStringImpl
/.../       Hi Asha     GStringImpl
$/.../$     Hi Asha     GStringImpl
"Hi there" has no placeholder, so it is a String
In a slashy string a Windows path keeps its backslashes: C:\Users\asha
A dollar-slashy string keeps / and \d+, and writes $ for a dollar sign
# The groovy command is Groovy 5.1.3 from here on.
$ groovy quotes.groovy > on-5.1.3.txt
$ diff on-5.1.3.txt on-6.0.0.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 quotes.groovy > on-4.0.33.txt
$ diff on-4.0.33.txt on-6.0.0.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 quotes.groovy > on-2.4.21.txt
$ diff on-2.4.21.txt on-6.0.0.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 quotes_session.py

The single-quoted forms printed ${name} exactly as typed. The other four put Asha in and are GStringImpl, Groovy’s implementation of a GString, while "Hi there", double-quoted but without a placeholder, is an ordinary String. The slashy string: A Groovy string written between slashes, such as /\d+/. Every backslash stays as written and only a slash needs escaping, so it suits regular expressions and Windows paths. It fills in ${} placeholders. kept every backslash of a Windows path, and $$ in the dollar-slashy string wrote one dollar sign. The output was the same on all four runtimes.

The same path in double quotes does not compile, because \U is not an escape that Groovy knows. The message you get depends on the runtime:

escapes.groovy GROOVY · escapes.groovy
def path = "C:\Users\asha"
println path
One bad escape, four messages Python · escapes_session.py
from session import jenkins_jar, run, use

# A Windows path in a double-quoted string, on the four runtimes: only the line that names the error is shown.
run("""
groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
""")
for version in ("5.1.3", "4.0.33"):
    use(version)
    run("""
    groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
    """)
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
""")

Output

$ groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
escapes.groovy: 1: Illegal escape character: '\U' @ line 1, column 15.
# The groovy command is Groovy 5.1.3 from here on.
$ groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
escapes.groovy: 1: Unexpected character: '"' @ line 1, column 12.
# The groovy command is Groovy 4.0.33 from here on.
$ groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
escapes.groovy: 1: Unexpected character: '"' @ line 1, column 12.
# 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 escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
escapes.groovy: 1: unexpected char: '\' @ line 1, column 15.

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

Groovy 6.0.0 names the problem, Illegal escape character: '\U'. Groovy 5.1.3 and 4.0.33 report Unexpected character: '"' at column 12, the opening quote, and Jenkins’ 2.4.21 says unexpected char: '\'. Write a Windows path as a slashy string, or double every backslash.

String Escape / Unescape Escape or unescape the backslashes and quotes of a text before you put it in a double-quoted string.

A GString reads its placeholders once, when it is built

A placeholder is evaluated when the GString is created, and the GString keeps the value it got. The documentation describes a second form, 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” expression: ${-> name} holds a small function instead of a value, and that function runs every time the GString becomes a String. Step through the program, then compare with its output:

Interactive · message by message When a GString reads its placeholders Step through lazy.groovy. Watch what each GString keeps when it is built, and what it reads when it is printed.
When a GString reads its placeholders, step 7 of 7. Messages so far: The script to eager: the value Asha; The script to lazy: the closure { -> name }; eager to The script: Hi Asha; lazy to The script: Hi Ravi.The scriptname = 'Ravi'eagerkeeps the list, not a copylazykeeps { -> name }the value Ashathe closure { -> name }Hi AshaHi Ravi

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

Every step as text
  1. name is Asha. The script sets name to 'Asha' and builds two GStrings from it, one with a plain placeholder and one whose placeholder starts with an arrow.
  2. Eager keeps a value. For "Hi ${name}" Groovy evaluates the placeholder at once and keeps what it got, the String Asha, next to the text Hi.
  3. Lazy keeps a closure. For "Hi ${-> name}" the placeholder is a closure. Groovy keeps the closure itself, so nothing has read name yet.
  4. name changes. The script gives name the value 'Ravi'. The eager GString does not notice, because it never looks at the variable again.
  5. Printing eager. Printing eager joins its text with the value it kept, so the session printed eager, Hi Asha.
  6. Printing lazy. Printing lazy calls the closure, which reads name now, so the session printed lazy, Hi Ravi. Every conversion to a String calls the closure again.
  7. Kept once, not copied. An eager placeholder runs once, when the GString is built, so count++ ran once. It keeps the object it got, not a copy, so report showed the stages list after test was added to it.

lazy.groovy GROOVY · lazy.groovy
def name = 'Asha'
def eager = "Hi ${name}"
def lazy = "Hi ${-> name}"
name = 'Ravi'
println "eager: $eager"
println "lazy:  $lazy"

def count = 1
def line = "count is ${count++}"
println line
println line
println "count is now $count"

def stages = ['build']
def report = "stages: ${stages}"
stages << 'test'
println report
Eager and lazy placeholders on four runtimes Python · lazy_session.py
from session import jenkins_jar, run, use

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

Output

$ groovy lazy.groovy | tee on-6.0.0.txt
eager: Hi Asha
lazy:  Hi Ravi
count is 1
count is 1
count is now 2
stages: [build, test]
# The groovy command is Groovy 5.1.3 from here on.
$ groovy lazy.groovy > on-5.1.3.txt
$ diff on-5.1.3.txt on-6.0.0.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 lazy.groovy > on-4.0.33.txt
$ diff on-4.0.33.txt on-6.0.0.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 lazy.groovy > on-2.4.21.txt
$ diff on-2.4.21.txt on-6.0.0.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 lazy_session.py

eager printed Hi Asha after name had changed, and lazy printed Hi Ravi. count++ ran once, when line was built, so the line said count is 1 twice and count ended at 2. The last line is the subtle one. report kept the list stages itself, not its text, so it printed [build, test] after test was added: a placeholder fixes which object is shown, but a mutable object can still change before the text is used.

Key idea

A placeholder runs once, when the GString is built, and keeps the object it got; ${-> expr} runs again each time the GString is turned into text.

Checkpoint

Check: The six string literals · When a GString reads its placeholders

  1. Question 1 of 2 Which literal holds the text C:\temp\new exactly, with both backslashes?

    Choose one answer.

    A hint

    In most literals a backslash starts an escape, and \t and \n are escapes.

    Show the answer: Question 1 of 2

    Answer: /C:\temp\new/

    Only the slashy forms leave backslashes alone. 'C:\temp\new' compiled, but it holds a tab and a newline, 9 characters, where /C:\temp\new/ holds all 11.

  2. Question 2 of 2 What does this print?

    Read the code, then choose one answer.

    def n = 1
    def msg = "n is ${n}"
    n = 2
    println msg
    Show the answer: Question 2 of 2

    Answer: n is 1

    A plain placeholder runs once, when the GString is created, and keeps the value it got. The closure form ${-> n} would print n is 2.

A GString is not a String, so map and set lookups miss

Groovy’s == compares a GString with a String by their text, which is why "api-${env}" == 'api-prod' is true. Java’s collections do not use Groovy’s ==. A hash map files each key under its hash code: The int that hashCode() returns, which hash maps and sets use to file and find a key. Equal objects must have equal hash codes, and a GString's hash code differs from that of a String with the same text. and compares keys with equals, and the documentation warns that a GString and a String with the same text have different hash codes. Step through one lookup:

Interactive · step by step Why tags['api-prod'] finds nothing Step through one lookup. Watch the hash code each key gives and where the map looks for the entry.
Why tags['api-prod'] finds nothing, step 5 of 5. Showing The key you ask for, The key in the map ('api-prod', a String), Its hash code, Its hash code (921144106), Where the map looks (the entry: '6.2.1'), Where the entry is. In focus: The key in the map, Its hash code.The key you ask for'api-prod', a StringThe key in the map'api-prod', a StringIts hash code921144106Its hash code921144106Where the map looksthe entry: '6.2.1'Where the entry isholds '6.2.1'

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

Every step as text
  1. The entry goes in. The map literal ["api-${env}": '6.2.1'] keeps its key as a GString. A hash map files every entry under the key's hash code, here 921144143.
  2. The lookup. tags['api-prod'] asks with a String. Its text is the same, but a String computes its hash code another way, 921144106.
  3. The wrong place. The map looks for the entry under 921144106 and finds nothing there, so the lookup returns null, as the session printed.
  4. Equal, but not equals. Even under the same hash code the entry would not match, because a map compares keys with equals, and equals between a GString and a String is false, as key.equals('api-prod') printed. Only Groovy's == called them equal.
  5. The fix. Turn the key into a String before it goes in, with toString() as in the fixed map. Then both keys give 921144106 and equals is true, so tags['api-prod'] found 6.2.1.

The program builds the same key into a map four ways, and into a list and a set:

keys.groovy GROOVY · keys.groovy
def env = 'prod'
def key = "api-${env}"
println "key == 'api-prod'             ${key == 'api-prod'}"
println "key.equals('api-prod')        ${key.equals('api-prod')}"
println "hash codes                    ${key.hashCode()} and ${'api-prod'.hashCode()}"

def tags = ["api-${env}": '6.2.1']
println "map literal: tags['api-prod'] ${tags['api-prod']}, key is a ${tags.keySet()[0].getClass().simpleName}"
println "map literal: tags[\"api-\${env}\"] ${tags["api-${env}"]}"
println "map literal: tags.get(\"api-\${env}\") ${tags.get("api-${env}")}"
def viaPut = [:]
viaPut.put("api-${env}", '6.2.1')
println "put(): ['api-prod']           ${viaPut['api-prod']}"
def viaSubscript = [:]
viaSubscript["api-${env}"] = '6.2.1'
println "subscript: ['api-prod']       ${viaSubscript['api-prod']}, key is a ${viaSubscript.keySet()[0].getClass().simpleName}"
println "list.contains('api-prod')     ${["api-${env}"].contains('api-prod')}"
println "set.contains('api-prod')      ${(["api-${env}"] as Set).contains('api-prod')}"
def fixed = [("api-${env}".toString()): '6.2.1']
println "toString() key: ['api-prod']  ${fixed['api-prod']}"
GString keys on four runtimes Python · keys_session.py
from session import jenkins_jar, run, use

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

Output

$ groovy keys.groovy | tee on-6.0.0.txt
key == 'api-prod'             true
key.equals('api-prod')        false
hash codes                    921144143 and 921144106
map literal: tags['api-prod'] null, key is a GStringImpl
map literal: tags["api-${env}"] null
map literal: tags.get("api-${env}") 6.2.1
put(): ['api-prod']           null
subscript: ['api-prod']       6.2.1, key is a String
list.contains('api-prod')     false
set.contains('api-prod')      false
toString() key: ['api-prod']  6.2.1
# The groovy command is Groovy 5.1.3 from here on.
$ groovy keys.groovy > on-5.1.3.txt
$ diff on-5.1.3.txt on-6.0.0.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 keys.groovy > on-4.0.33.txt
$ diff on-4.0.33.txt on-6.0.0.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 keys.groovy > on-2.4.21.txt
$ diff on-2.4.21.txt on-6.0.0.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 keys_session.py

The hash codes differ, 921144143 for the GString and 921144106 for the String, and equals is false. So the map literal and put() kept a GStringImpl key that ['api-prod'] could not find, and the list and the set did not contain 'api-prod' either. Even tags["api-${env}"], written exactly like the key, returned null: Groovy’s subscript operator turns a GString into a String, both when it stores a key and when it looks one up. That is why the map filled with viaSubscript["api-${env}"] = … worked, while Java’s own get(), given the GString, found the GString key.

Key idea

== compares text, but maps, sets and contains use equals and hashCode, where a GString and a String differ: turn a GString into a String at the moment it becomes a key.

Mixing the two kinds of key is what goes wrong in practice, and the subscript operator’s conversion hides it from a quick test that both stores and reads through [].

Building keys and set elements from GStrings

A map literal or put() with "api-${env}" stores a GString, and a later lookup with ['api-prod'] or even with ["api-${env}"] misses it. Call toString() wherever a GString becomes a map key, a set element or a list item that is searched later, as the fixed map does.

Checkpoint

Check: GStrings as keys in maps, sets and lists

  1. One question def set = ["${host}"] as Set, with host = 'db1'. What does set.contains('db1') return?

    Choose one answer.

    Show the answer: One question

    Answer: false

    Java collections compare with equals and hashCode, where a GString and a String with the same text differ. Build the element with "${host}".toString() and contains('db1') is true.

The GDK adds the string methods that Java lacks

The Groovy Development Kit adds methods to Java’s String: operators such as * and -, padding, splitting, indexing with ranges, and two methods that tidy multi-line text. The first program shows one line of each; the second builds two multi-line strings with stripIndent() and stripMargin(), and the third shows a trap of stripIndent():

gdk.groovy, multiline.groovy and indent.groovy

GROOVY (gdk.groovy) · gdk.groovy

// String methods that the Groovy Development Kit adds to Java's String.
def show(String code, Object value) {
    println "${code.padRight(30)}${value instanceof CharSequence ? "'$value'" : value}"
}

show "'ab' * 3", 'ab' * 3
show "'build-42-final' - '-final'", 'build-42-final' - '-final'
show "'a-b-c' - '-'", 'a-b-c' - '-'
show "'7'.padLeft(3, '0')", '7'.padLeft(3, '0')
show "'id'.padRight(6, '.')", 'id'.padRight(6, '.')
show "'ok'.center(8, '*')", 'ok'.center(8, '*')
show "'a,,b,'.split(',')", 'a,,b,'.split(',') as List
show "'a,,b,'.split(',', -1)", 'a,,b,'.split(',', -1) as List
show "'a,,b,'.tokenize(',')", 'a,,b,'.tokenize(',')
show "'x=1;y=2'.tokenize(';=')", 'x=1;y=2'.tokenize(';=')
show "'Groovy'[0..2]", 'Groovy'[0..2]
show "'Groovy'[-1]", 'Groovy'[-1]
show "'banana'.count('an')", 'banana'.count('an')

GROOVY (multiline.groovy) · multiline.groovy

def report = '''\
    Build 42
      api: passed
      web: failed'''.stripIndent()
println report
def config = '''\
    |retries=3
    |  timeout=30'''.stripMargin()
println config
println "readLines(): ${config.readLines()}"

GROOVY (indent.groovy) · indent.groovy

// The closing ''' on a line of its own: Java's stripIndent() and Groovy's stripIndent(true).
def query = '''\
    SELECT name
      FROM builds
'''
println "stripIndent():     ${query.stripIndent().readLines().collect { "|$it|" }}"
println "stripIndent(true): ${query.stripIndent(true).readLines().collect { "|$it|" }}"
GDK string methods and multi-line text, on four runtimes Python · gdk_session.py
from session import jenkins_jar, run, use

# The GDK string methods and multi-line text on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy gdk.groovy | tee gdk-6.0.0.txt
groovy multiline.groovy | tee multiline-6.0.0.txt
groovy indent.groovy | tee indent-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
    use(version)
    run(f"""
    groovy gdk.groovy > gdk-{version}.txt
    groovy multiline.groovy > multiline-{version}.txt
    groovy indent.groovy > indent-{version}.txt
    diff gdk-{version}.txt gdk-6.0.0.txt && diff multiline-{version}.txt multiline-6.0.0.txt && diff indent-{version}.txt indent-6.0.0.txt && echo 'Groovy {version} printed the same lines'
    """)
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain gdk.groovy > gdk-2.4.21.txt
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain multiline.groovy > multiline-2.4.21.txt
diff gdk-2.4.21.txt gdk-6.0.0.txt && diff multiline-2.4.21.txt multiline-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain indent.groovy 2>&1 | grep -m2 -o -e '^stripIndent().*' -e 'No signature of method: [^ ]* is applicable for argument types: ([^)]*)'
""")

Output

$ groovy gdk.groovy | tee gdk-6.0.0.txt
'ab' * 3                      'ababab'
'build-42-final' - '-final'   'build-42'
'a-b-c' - '-'                 'ab-c'
'7'.padLeft(3, '0')           '007'
'id'.padRight(6, '.')         'id....'
'ok'.center(8, '*')           '***ok***'
'a,,b,'.split(',')            [a, , b]
'a,,b,'.split(',', -1)        [a, , b, ]
'a,,b,'.tokenize(',')         [a, b]
'x=1;y=2'.tokenize(';=')      [x, 1, y, 2]
'Groovy'[0..2]                'Gro'
'Groovy'[-1]                  'y'
'banana'.count('an')          2
$ groovy multiline.groovy | tee multiline-6.0.0.txt
Build 42
  api: passed
  web: failed
retries=3
  timeout=30
readLines(): [retries=3,   timeout=30]
$ groovy indent.groovy | tee indent-6.0.0.txt
stripIndent():     [|    SELECT name|, |      FROM builds|]
stripIndent(true): [|SELECT name|, |  FROM builds|]
# The groovy command is Groovy 5.1.3 from here on.
$ groovy gdk.groovy > gdk-5.1.3.txt
$ groovy multiline.groovy > multiline-5.1.3.txt
$ groovy indent.groovy > indent-5.1.3.txt
$ diff gdk-5.1.3.txt gdk-6.0.0.txt && diff multiline-5.1.3.txt multiline-6.0.0.txt && diff indent-5.1.3.txt indent-6.0.0.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 gdk.groovy > gdk-4.0.33.txt
$ groovy multiline.groovy > multiline-4.0.33.txt
$ groovy indent.groovy > indent-4.0.33.txt
$ diff gdk-4.0.33.txt gdk-6.0.0.txt && diff multiline-4.0.33.txt multiline-6.0.0.txt && diff indent-4.0.33.txt indent-6.0.0.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 gdk.groovy > gdk-2.4.21.txt
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain multiline.groovy > multiline-2.4.21.txt
$ diff gdk-2.4.21.txt gdk-6.0.0.txt && diff multiline-2.4.21.txt multiline-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
Groovy 2.4.21 printed the same lines
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain indent.groovy 2>&1 | grep -m2 -o -e '^stripIndent().*' -e 'No signature of method: [^ ]* is applicable for argument types: ([^)]*)'
stripIndent():     [|    SELECT name|, |      FROM builds|]
No signature of method: java.lang.String.stripIndent() is applicable for argument types: (java.lang.Boolean)

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

'a-b-c' - '-' printed 'ab-c': the GDK documents minus as removing the first occurrence only. split(',') and tokenize(',') differ in a way that matters for comma-separated data: split kept the empty field between the two commas and dropped the empty one at the end, split(',', -1) kept both, and tokenize dropped every empty field; tokenize(';=') split at either character. In the multi-line text, stripIndent() removed the four spaces that every line shared and kept the extra two, and stripMargin() removed everything up to each line’s |. The backslash after the opening ''' keeps the text from starting with a newline.

The trap comes from Java. Since Java 15, String has a stripIndent() of its own, and on a String Groovy calls that method, as the GDK’s documentation of stripIndent(boolean) says. Java counts the indentation of the last line even when it is empty, so in indent.groovy, whose closing ''' starts a line of its own, stripIndent() removed nothing, on all four runtimes. stripIndent(true) applies Groovy’s rule, which skips blank lines, and removed the four spaces; it exists from Groovy 3, and Jenkins’ 2.4.21 answered No signature of method. Close the quotes at the end of the last line, as multiline.groovy does, and both rules agree.

Lakh and crore grouping

Java’s NumberFormat for the locale en-IN printed ₹12,345,678.50 on Temurin 25. Java’s DecimalFormat uses one group size, the last one in its pattern, so it cannot write lakh and crore grouping. The GDK methods can: the program below splits the whole part into pairs with reverse(), collate(2) and join(), and printed ₹1,23,45,678.50.

india.groovy GROOVY · india.groovy
import java.math.RoundingMode
import java.text.NumberFormat

def amount = 12345678.5
def india = Locale.forLanguageTag('en-IN')
println "NumberFormat for en-IN: ${NumberFormat.getCurrencyInstance(india).format(amount)}"

String groupForIndia(BigDecimal value) {
    def (whole, fraction) = value.setScale(2, RoundingMode.HALF_UP).toPlainString().tokenize('.')
    if (whole.size() <= 3) return "$whole.$fraction"
    def pairs = whole[0..-4].reverse().toList().collate(2)*.join()*.reverse().reverse()
    "${pairs.join(',')},${whole[-3..-1]}.$fraction"
}
println "grouped by hand:        ₹${groupForIndia(amount)}"
println "grouped by hand:        ₹${groupForIndia(999.99)}"
println "grouped by hand:        ₹${groupForIndia(100000)}"
Digit grouping by hand, on four runtimes Python · india_session.py
from session import jenkins_jar, run, use

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

Output

$ groovy india.groovy | tee on-6.0.0.txt
NumberFormat for en-IN: ₹12,345,678.50
grouped by hand:        ₹1,23,45,678.50
grouped by hand:        ₹999.99
grouped by hand:        ₹1,00,000.00
# The groovy command is Groovy 5.1.3 from here on.
$ groovy india.groovy > on-5.1.3.txt
$ diff on-5.1.3.txt on-6.0.0.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 india.groovy > on-4.0.33.txt
$ diff on-4.0.33.txt on-6.0.0.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 india.groovy > on-2.4.21.txt
$ diff on-2.4.21.txt on-6.0.0.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 india_session.py

In a Jenkinsfile, Groovy fills in placeholders before the shell starts

The Jenkins documentation says that Pipeline “uses rules identical to Groovy for string interpolation”. A double-quoted argument of an sh step is therefore a GString, and Groovy puts every value into the text before the shell runs. For a build parameter, that can turn data into a command; for credentials, the documentation is blunt: “Groovy string interpolation should never be used with credentials.” Step through what reaches the shell:

Interactive · message by message Who fills in the parameter, Groovy or the shell? Step through the two commands of shell.groovy. Watch the text that reaches the shell in each case.
Who fills in the parameter, Groovy or the shell?, step 5 of 5. Messages so far: Groovy to The shell: echo hello; echo SECOND …; Groovy to The shell: echo "$STATEMENT".Groovybuilds the command textThe shellran one commandecho hello; echo SECOND …echo "$STATEMENT"

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

Every step as text
  1. A parameter with a semicolon. The parameter's value is hello; echo SECOND COMMAND RAN. In a Jenkinsfile it would come from the build's parameters, which the person who starts the build types in.
  2. Double quotes. For "echo ${statement}" Groovy puts the value into the text before anything runs. The shell receives echo hello; echo SECOND COMMAND RAN, a string with two commands in it.
  3. Two commands ran. The semicolon ends the first command, so the shell ran both and printed hello and then SECOND COMMAND RAN. Text that was data became a command.
  4. Single quotes. For 'echo "$STATEMENT"' Groovy builds no GString, so the text reaches the shell unchanged, and the value travels separately, in the environment variable STATEMENT.
  5. One command, the value as text. The shell expands $STATEMENT inside double quotes as one word, so echo printed the whole value, semicolon included, and ran nothing else.

The program reproduces both cases on your own computer, without Jenkins: it builds two commands from a parameter that contains a semicolon and runs each with sh -c, with the value also in the environment variable STATEMENT:

shell.groovy GROOVY · shell.groovy
// What the shell receives when a parameter goes into a command, two ways.
def statement = 'hello; echo SECOND COMMAND RAN'
def interpolated = "echo ${statement}"
def literal = 'echo "$STATEMENT"'

[interpolated, literal].each { command ->
    def builder = new ProcessBuilder('sh', '-c', command).redirectErrorStream(true)
    builder.environment().put('STATEMENT', statement)
    def printed = builder.start().text.readLines()
    println "command text:  $command"
    println "shell printed: ${printed.join(' | ')}"
}
A parameter in a shell command, two ways Python · shell_session.py
from session import jenkins_jar, run, use

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

Output

$ groovy shell.groovy | tee on-6.0.0.txt
command text:  echo hello; echo SECOND COMMAND RAN
shell printed: hello | SECOND COMMAND RAN
command text:  echo "$STATEMENT"
shell printed: hello; echo SECOND COMMAND RAN
# The groovy command is Groovy 5.1.3 from here on.
$ groovy shell.groovy > on-5.1.3.txt
$ diff on-5.1.3.txt on-6.0.0.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 shell.groovy > on-4.0.33.txt
$ diff on-4.0.33.txt on-6.0.0.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 shell.groovy > on-2.4.21.txt
$ diff on-2.4.21.txt on-6.0.0.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 shell_session.py

With double quotes the shell received two commands and ran both, so SECOND COMMAND RAN appeared. With single quotes Groovy built no GString, the command text arrived unchanged, and the shell read the value from its environment and printed it as text. The Jenkins documentation gives the same fix for a Jenkinsfile: send sh a single-quoted command and let the shell read the parameter or the credential from the environment that Jenkins has filled in.

Spot it when you read a Jenkinsfile

Look for sh "…${…}…" whose placeholder holds a build parameter, an environment value or a credential. Rewrite it as sh '…"$NAME"…', with the value passed in the environment, and the shell will treat it as data.

Checkpoint

Check: The GDK's string methods · Interpolation in Jenkins sh steps

  1. Question 1 of 2 What do 'x,,y,'.split(',') and 'x,,y,'.tokenize(',') return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [x, , y] and [x, y]

    The session printed [a, , b] for split(','), [a, , b, ] for split(',', -1) and [a, b] for tokenize(','). Choose split when an empty field means something.

  2. Question 2 of 2 A Jenkinsfile runs sh "echo ${params.MESSAGE}", and MESSAGE is also in the environment of the step. Which rewrite keeps the parameter out of the command text?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: sh 'echo "$MESSAGE"'

    In shell.groovy the double-quoted command ran a second command hidden in the value, while the single-quoted one printed the value as text. The Jenkins documentation recommends the same: single quotes, and let the shell expand the variable.

Practise: strings and GStrings

Work through the string questions below. They begin with whichever idea your answers show to be weakest, and each reason quotes a session of this page:

Practice that adapts to you

Practise this lesson

16 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 16 Which literal never fills in ${} placeholders?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: A single-quoted string, '…'

    Only the single-quoted forms are never interpolated. The other four fill in ${} and become GStrings when the text holds a placeholder.

  2. Question 2 of 16 Which classes are "build ${number}" and "build 42"?

    Choose one answer.

    Show the answer: Question 2 of 16

    Answer: GStringImpl and String

    Double quotes give a GString only when there is something to fill in. "Hi there" printed String in the session, and "Hi ${name}" printed GStringImpl.

  3. Question 3 of 16 A script must hold the Windows path C:\Users\asha\builds and run on Groovy 2.4.21 and 6.0.0. Which literal compiles on both and keeps the path exactly?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: /C:\Users\asha\builds/

    Any literal that processes backslash escapes refuses \U. A slashy string, or doubling every backslash, keeps the path on every runtime.

  4. Question 4 of 16 When does Groovy evaluate the expression in "${expr}"?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: Once, when the GString is created

    A plain placeholder is evaluated once, at creation, and the GString keeps the result; ${-> expr} defers it to every conversion to text.

  5. Question 5 of 16 What does this print?

    Read the code, then choose one answer.

    def status = 'running'
    def line = "status: ${-> status}"
    status = 'done'
    println line
    Show the answer: Question 5 of 16

    Answer: status: done

    ${-> status} keeps a closure, and the closure runs each time the GString becomes text, so it sees the latest value.

  6. Question 6 of 16 A build log line is built as def entry = "stages: ${stages}" before the build runs and printed at the end. Meanwhile stages << 'deploy' ran. What does the line show?

    Choose one answer.

    Show the answer: Question 6 of 16

    Answer: The list with deploy in it, because the GString kept the list itself

    An eager placeholder fixes which object is shown, not its contents. To freeze the text, build it with "stages: ${stages}".toString() or copy the list first.

  7. Question 7 of 16 With gs = "api-${env}" and env = 'prod', which comparison is true?

    Choose one answer.

    Show the answer: Question 7 of 16

    Answer: gs == 'api-prod'

    Only Groovy's == treats the two as equal. equals and hashCode, which maps and sets rely on, see two different objects.

  8. Question 8 of 16 Which of these maps returns 6.2.1 for m['api-prod'], with env = 'prod'?

    Choose one answer.

    Show the answer: Question 8 of 16

    Answer: def m = [:]; m["api-${env}"] = '6.2.1'

    Groovy's subscript assignment converted the key to a String, while the map literal and put() kept the GString. Calling toString() yourself makes the result clear whichever way the map is filled.

  9. Question 9 of 16 A cache stores entries with cache.put("${user}:${repo}", result) and looks them up with a String read from a request. It never hits. What do you change?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: Store the key as a String, with "${user}:${repo}".toString()

    Turn GStrings into Strings at the point where they become keys. The session's fixed map, built with toString(), found 6.2.1.

  10. Question 10 of 16 What does 'a-b-c' - '-' give?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: 'ab-c'

    minus replaces the first occurrence of the target with an empty string. replace('-', '') removes them all.

  11. Question 11 of 16 Which call turns '42' into '0042'?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: '42'.padLeft(4, '0')

    padLeft(width, filler) pads on the left to the width; padRight and center pad elsewhere.

  12. Question 12 of 16 A line 'asha,ravi,' has an empty last field that the program must keep. Which call returns three fields?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: 'asha,ravi,'.split(',', -1)

    Only split with a negative limit keeps the empty last field; for real CSV with quoted fields, use a CSV parser.

  13. Question 13 of 16 A script ends a multi-line SQL text with ''' at the start of its own line and calls stripIndent(), but on Temurin 25 every line keeps its leading spaces. What fixes it on Groovy 3 and later?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: stripIndent(true), which applies Groovy's rule and skips the blank last line

    Since Java 15 a String has its own stripIndent(), and Groovy calls it. Java counts the indentation of the last line even when it is empty, so a closing ''' at the start of a line leaves the text as it was. stripIndent(true), from Groovy 3, applies Groovy's rule; on Groovy 2.4.21, close the quotes at the end of the last line instead.

  14. Question 14 of 16 In sh "deploy ${params.TARGET}", who replaces ${params.TARGET} with its value?

    Choose one answer.

    Show the answer: Question 14 of 16

    Answer: Groovy, before the sh step starts the shell

    A double-quoted argument is a GString, and Groovy fills it in before the step runs; the shell then receives whatever text the value produced.

  15. Question 15 of 16 Why does the Jenkins documentation say credentials must never be interpolated into a sh command?

    Choose one answer.

    Show the answer: Question 15 of 16

    Answer: The credential becomes part of the command text, where process tools can see it and shell characters in it run

    Interpolation puts the credential into the command line. Single quotes keep it out, and the shell reads it from the environment that withCredentials or environment filled in.

  16. Question 16 of 16 A parameter TARGET may contain ; and $. Which step passes it to deploy as one argument, whatever it holds?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: sh 'deploy --target "$TARGET"'

    Keep the value out of the command text and quote it for the shell: single quotes for Groovy, double quotes around "$TARGET" for the shell.

Interview questions

Warm-up (fresher): What is the difference between 'Hi ${name}' and "Hi ${name}" in Groovy?

The single-quoted literal is a plain java.lang.String and keeps the characters ${name} as they are; single-quoted and triple-single-quoted strings never interpolate. The double-quoted literal is a GString: Groovy evaluates name when the string is built and puts its value into the text, so it prints Hi Asha when name is 'Asha'. A double-quoted string without any placeholder is an ordinary String. Because a GString is a different class, it is equal to a String with the same text under Groovy’s == but not under equals, and it has a different hash code, so it should be turned into a String with toString() before it is used as a map key. In a Jenkinsfile, single quotes also keep parameters and secrets out of the command text of an sh step.

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. Fresher to mid

    A deployment script builds a map of image tags with keys written "${service}-${env}". Printing the map shows api-prod, but tags['api-prod'] returns null. Explain the cause, give two fixes, and say how a test would have caught it.

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

  2. Mid to senior

    Reading a colleague's Jenkinsfile, you see sh "deploy --token ${env.DEPLOY_TOKEN} --target ${params.TARGET}". What two problems does it have, how do you rewrite it, and how would you show that the rewrite fixes them?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Single-quoted strings are plain Strings; double-quoted, triple-double-quoted, slashy and dollar-slashy strings fill in ${} and become GStrings, and slashy strings keep backslashes for paths and patterns.
  • A placeholder is evaluated once, when the GString is built, and keeps the object it got; ${-> expr} is evaluated every time the text is used.
  • A GString equals a String under Groovy’s ==, but not under equals, and its hash code differs, so maps, sets and contains miss it; convert keys with toString().
  • The GDK adds *, -, padLeft, center, tokenize, ranges as indexes, stripIndent() and stripMargin(); tokenize drops empty fields where split keeps the inner ones, and on Java 15 and later a String’s stripIndent() is Java’s own, so stripIndent(true) asks for Groovy’s rule.
  • In a Jenkinsfile a double-quoted sh argument is filled in by Groovy before the shell runs, so parameters and secrets belong in single-quoted commands that read them from the environment.

Terms in this lesson

GString
groovy.lang.GString, the class of a double-quoted string that holds a placeholder such as ${name}. It keeps its text and its values apart and joins them when it is used as text, so it equals a String under == but not under equals.
hash code
The int that hashCode() returns, which hash maps and sets use to file and find a key. Equal objects must have equal hash codes, and a GString's hash code differs from that of a String with the same text.
interpolation
Filling a placeholder such as ${name} in a string with the value of the expression inside it. Groovy interpolates double-quoted, triple-double-quoted, slashy and dollar-slashy strings, never single-quoted ones.
slashy string
A Groovy string written between slashes, such as /\d+/. Every backslash stays as written and only a slash needs escaping, so it suits regular expressions and Windows paths. It fills in ${} placeholders.

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.