Control Structures

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 Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). Groovy’s control structures look like Java’s, but three things change how they behave: any object can be a condition (the Groovy truth), switch is driven by a method called isCase instead of equality, and the loop and instanceof syntax has been extended over the releases (switch expressions in 4.0, the for index variable in 5.0, the switch position rule, labelled blocks and instanceof pattern scoping in 6.0). For the Java baseline see Java Control Flow.

if / else and conditional expressions

if, else if and else work as in Java, with one difference: the condition does not have to be a boolean. Groovy applies the Groovy truth, so null, an empty string, an empty collection and 0 are false. The ternary operator and the Elvis operator are expressions built on the same rule.

def moisture = 42
if (moisture < 20) {
    println 'water now'
} else if (moisture < 60) {
    println 'ok'
} else {
    println 'too wet'
}
def zone = null
if (zone) println 'has zone' else println 'no zone (null is false)'
println(moisture > 30 ? 'fine' : 'dry')
ok
no zone (null is false)
fine

switch and isCase

A Groovy switch does not compare with ==. For each case, it evaluates caseValue.isCase(switchValue) and runs the branch when that returns true (through the Groovy truth). The GDK supplies an isCase for the common types, which is why one switch can mix very different kinds of labels. The switch value can be any type, and the labels need not even be constants.

The flowchart shows the matching order; the example after it exercises every kind of label.

flowchart TD S["switch (value)"] --> C1{"case 1: label.isCase(value)?"} C1 -->|"true"| B1["run branch 1"] C1 -->|"false"| C2{"case 2: label.isCase(value)?"} C2 -->|"true"| B2["run branch 2"] C2 -->|"false"| CN{"more cases?"} CN -->|"yes"| C2 CN -->|"no"| D{"default present?"} D -->|"yes"| BD["run default"] D -->|"no"| E["leave the switch"] B1 --> F{"break, return or end of switch?"} B2 --> F F -->|"yes"| E F -->|"no (fall-through)"| NX["run next branch body"] NX --> F

The cases are tried in source order and the first match wins, so put specific labels before general ones: in the example below, Integer has to come after the range and the list or it would swallow them.

def classify(x) {
    switch (x) {
        case null:             return 'null'
        case 'frost':          return 'the word frost'
        case ~/temp-\d+/:      return 'regex match'
        case 0..9:             return 'single digit'
        case [10, 20, 30]:     return 'a round number'
        case Integer:          return 'some other int'
        case { it instanceof Number && it > 1000 }: return 'huge number'
        case List:             return 'a list'
        default:               return 'unknown'
    }
}
[null, 'frost', 'temp-21', 7, 20, 99, 5000.5, [1], 'xyz'].each { println "${it.inspect()} -> ${classify(it)}" }
null -> null
'frost' -> the word frost
'temp-21' -> regex match
7 -> single digit
20 -> a round number
99 -> some other int
5000.5 -> huge number
[1] -> a list
'xyz' -> unknown

The label types and what their isCase means:

Label Matches when

A class (Integer, List)

The value is an instance of the class (Class.isInstance)

A range (0..9)

The range contains the value

A regex pattern (~/temp-\d/+)

The pattern matches the whole string (matches, not find)

A collection ([10, 20, 30])

The collection contains the value

A closure

The closure, called with the value, returns a true result

null

The value is null

Any other object

equals, unless its class defines its own isCase

Because dispatch is just a method call, you can make your own types usable as labels by defining isCase, and you can call the method directly outside a switch. Control flow is the classic C-style one: a matching branch falls through into the next unless it ends with break, return or throw.

// fall-through
def fall(n) {
    def out = []
    switch (n) {
        case 1: out << 'one'
        case 2: out << 'two'; break
        case 3: out << 'three'
    }
    out
}
println fall(1); println fall(2); println fall(3)

// a custom isCase
class Band { int lo, hi; boolean isCase(v) { v >= lo && v <= hi } }
def cold = new Band(lo: -50, hi: 5)
switch (3) { case cold: println 'cold band'; break; default: println 'other' }

// isCase is an ordinary method
println(cold.isCase(3))
println('abc'.isCase('abc'))
println(Integer.isCase(1))
println((1..5).isCase(3))
println([1, 2].isCase(2))
[one, two]
[two]
[three]
cold band
true
true
true
true
true

A switch used for classification (by type, range or pattern) is where this pays off most. See Regular Expressions for regex labels and Ranges for range labels. Closure labels are covered in Closures.

switch expressions

Since Groovy 4.0 a switch can be used as an expression that produces a value, with the arrow form case label -> result. An arrow branch never falls through, so no break is needed. Several labels can share a branch (case 'S', 'M' ->), a branch can be a block, and a block produces its value with yield. The colon form can also be an expression, in which case each branch ends with yield. The labels are still matched through isCase, so ranges, closures and classes work as before.

def label(int t) {
    def r = switch (t) {
        case 0..4 -> 'frost risk'
        case 5..25 -> 'comfortable'
        case { it > 25 } -> 'hot'
        default -> 'below zero'
    }
    r
}
println label(2); println label(12); println label(31); println label(-3)

def size = 'M'
def liters = switch (size) {
    case 'S', 'M' -> 5
    case 'L' -> {
        def base = 8
        yield base * 2
    }
    default -> 0
}
println liters

def old = switch (size) {
    case 'S':
    case 'M':
        yield 5
    default:
        yield 0
}
println old
frost risk
comfortable
hot
below zero
5
5

The position decides (Groovy 6.0)

Groovy 6.0 adopts Java’s rule that the arrow decides fall-through while the position decides whether the switch produces a value. A switch used as a statement, even with arrows, therefore does not have to cover every value. A switch used as an expression must, and a value that no branch handles fails at run time. Statically compiled switch statements also gain tableswitch/lookupswitch dispatch, and a duplicate label is diagnosed for statements as it already was for expressions.

def run(x) {
    // statement position: an arrow switch need not be exhaustive
    switch (x) {
        case 1 -> println 'one'
        case 2 -> println 'two'
    }
    println "done $x"
}
run(1); run(9)

try {
    def v = switch (9) { case 1 -> 'one' }   // expression position: must cover 9
    println v
} catch (Throwable e) {
    println "${e.class.name}: ${e.message}"
}
one
done 1
done 9
java.lang.IllegalStateException: the switch expression does not cover the value 9

When writing an expression switch, add a default branch unless the labels are provably complete. Code written for Groovy 5 or earlier that relied on the old behaviour of arrow switch statements should be re-run on 6.0.

Loops

The classic for loop

The C-style for accepts several variables and several update expressions, separated by commas, in addition to the single-variable form.

for (int i = 0; i < 3; i++) print "$i "
println()
for (int i = 0, j = 10; i < j; i += 4, j -= 1) println "i=$i j=$j"
0 1 2
i=0 j=10
i=4 j=9

for in

for (x in iterable) iterates over anything Groovy can iterate: ranges, lists, arrays, maps (as entries), strings (as characters) and any object with an iterator(). Java’s for (x : iterable) form is also accepted. Since Groovy 5.0 an extra leading variable receives the zero-based index, with or without types.

for (z in ['north', 'south']) println z
for (c in 'abc') print c.toUpperCase()
println()
for (e in [a: 1, b: 2]) println "${e.key}=${e.value}"

// Groovy 5.0: index variable first
for (i, z in ['north', 'south']) println "$i: $z"
for (int i, String z in ['x', 'y']) println "$i/$z"
north
south
ABC
a=1
b=2
0: north
1: south
0/x
1/y

while and do / while

while is unchanged. do/while has been available since Groovy 3.0 (the Parrot parser); the older parser did not support it.

int n = 3
while (n > 0) { print n-- ; print ' ' }
println()
int k = 0
do { k++ } while (k < 5)
println k
3 2 1
5

Groovy 6.0 also gives each iteration of a for in loop its own binding of the loop variable when a closure, lambda or anonymous inner class captures it, so deferred code sees the value of the iteration that created it, as in Java. The old single shared binding can be restored with the system property groovy.forin.per.iteration.capture=false.

Idiomatic Groovy often replaces loops with closures such as each, eachWithIndex, times or (1..3).each { }, see Closures and Working with Collections. Note that break and continue do not apply to those closure-based iterations (see below).

break, continue and labels

break leaves the innermost loop or switch; continue skips to the next iteration. A label (name:) before a statement lets break label and continue label target an outer loop. Since Groovy 6.0 a label can also precede a plain block, and break label then leaves that block, which gives a structured early exit without a helper method.

outer:
for (i in 1..3) {
    for (j in 1..3) {
        if (j == 2) continue
        if (i == 2) continue outer
        if (i == 3) break outer
        println "i=$i j=$j"
    }
}
println 'after'

// Groovy 6.0: labelled break out of a plain block
found: {
    for (x in [4, 8, 15]) if (x > 5) { println "first > 5: $x"; break found }
    println 'none'
}

for (i in 1..5) { if (i % 2) continue; println i; if (i > 3) break }

// inside a closure, return only ends this call of the closure
[1, 2, 3].each { if (it == 2) return; println it }
i=1 j=1
i=1 j=3
after
first > 5: 8
2
4
1
3

The last line is a common trap: inside the closure of each, return behaves like continue (it ends only that call), and break is not available at all. To stop early use a real for loop, find, any or takeWhile, or throw.

instanceof and pattern variables

Groovy accepts Java’s instanceof type pattern (JEP 394): x instanceof String s tests and binds s in one step. Since Groovy 6.0 the scope of the pattern variable follows Java’s flow rules, and the negated form !instanceof introduces a binding too. The variable is in scope only where the test is known to be true: in the then branch for instanceof, in the else branch for !instanceof, and on the right-hand side of &&. Elsewhere, dynamic code fails with a MissingPropertyException and statically checked code with a compile error. This is a breaking change from earlier releases, where the variable was visible more widely in dynamic code.

Object o = 'greenhouse'
if (o instanceof String s && s.size() > 5) println s.toUpperCase()
if (o !instanceof Integer) println 'not an int'
if (!(o instanceof Integer i)) println 'neg form'

Object p = 42
if (p !instanceof Integer n) { println 'no' } else { println n + 1 }
GREENHOUSE
not an int
neg form
43

Compared with Java, Groovy’s dynamic typing makes the cast less necessary, because a method call is resolved on the runtime type anyway; the pattern variable matters mostly under static type checking, where it replaces the cast. For Java’s own pattern matching, including records and switch patterns, see Java Pattern Matching.

Where Groovy 6.0 and older books differ

Books written for Groovy 1.x and 2.x, including the first edition of Groovy in Action (Groovy 1.0), describe a switch that only has the colon form and cannot produce a value, loops without the index variable, no do/while, and no instanceof patterns. All of the following are later additions: the do/while loop and the !instanceof operator (3.0), switch expressions (4.0), instanceof patterns and the for index variable (5.0), and the position rule, labelled blocks, binding a pattern variable with !instanceof and the flow-scoped pattern variable (6.0). The isCase dispatch, fall-through and the Groovy truth described above have not changed.