Working with Collections

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 keeps the JDK collection classes (ArrayList, LinkedHashMap, HashSet) and adds to them the methods that make collection code short: each, collect, findAll, inject, groupBy, sort and several hundred more. Almost all of them take a closure (see Closures) and, since 6.0, also the java.util.function interfaces. The literal syntax, indexing and operators for lists, maps and ranges are on Lists & Arrays, Maps and Ranges, and SQL-like queries over collections are on GINQ: SQL-like Collection Queries. The Java side is described on Java Collections Framework and Java Streams & Collectors, and the Kotlin counterpart on Kotlin Collections & Sequences.

All the examples work on the same data, the readings of a small greenhouse: nine measurements from three zones, each a temperature or a humidity value taken at a given hour. Every snippet below is a fragment that uses the readings list declared once here (a record, see Records & Sealed Types); the output shown is what that snippet printed when it was run after this setup.

record Reading(String zone, String kind, double value, int hour) { }

def readings = [
    new Reading('north', 'temp',     21.5,  6), new Reading('north', 'temp',     24.0,  9),
    new Reading('north', 'humidity', 61.0,  6), new Reading('south', 'temp',     28.5,  6),
    new Reading('south', 'temp',     31.2,  9), new Reading('south', 'humidity', 48.0,  9),
    new Reading('east',  'temp',     19.0,  6), new Reading('east',  'temp',     22.4,  9),
    new Reading('east',  'humidity', 70.5,  9)
]

Two habits save time on this page. First, always write println(expr) with parentheses when the expression starts with [: println [1, 2].size() is parsed as an index into println and fails with a MissingPropertyException. Second, check whether a method mutates its receiver (sort, unique, reverse in some forms) or returns a new collection (toSorted, toUnique, findAll); the GDK uses both styles and the names do not always say which.

The shape of the pipeline

The most useful way to read GDK collection code is as a pipeline in which each step takes a collection and returns a new one: filter, reshape, group and summarise. The diagram shows the pipeline built later on this page: the temperature readings are selected with findAll, reduced to the fields that matter with collect, grouped by zone with groupBy, and finally turned into a map of averages with collectEntries.

flowchart LR R["readings: 9 Reading records"] -->|"findAll { it.kind == 'temp' }"| T["6 temperature readings"] T -->|"collect { [zone, celsius] }"| P["6 small maps"] P -->|"groupBy { it.zone }"| G["map: zone to list of readings"] G -->|"collectEntries { zone, rs -> average }"| A["north: 22.75, south: 29.85, east: 20.7"]

Iterating

each calls a closure for every element; eachWithIndex adds the position, reverseEach walks a list backwards, and indexed() turns a list into a map of index to element (with an optional starting index) so the position can be used with the other methods. On a map, a closure with two parameters receives the key and value, and one with a single parameter receives the Map.Entry.

readings.each { print "${it.zone} " }
println()
readings.eachWithIndex { r, i -> if (i < 3) print "$i:${r.value} " }
println()
readings.reverseEach { print "${it.hour} " }
println()
readings.indexed(1).each { i, r -> if (i <= 2) print "$i->${r.zone} " }
println()
[a: 1, b: 2].each { k, v -> print "$k=$v " }
[a: 1, b: 2].eachWithIndex { e, i -> print "$i:${e.key} " }
println()

// each cannot break; use find, any, every or a for loop to stop early
println readings.find { it.value > 25 }
for (r in readings) { if (r.value > 25) { println "stop at ${r.zone}"; break } }
north north north south south south east east east
0:21.5 1:24.0 2:61.0
9 9 6 9 9 6 6 9 6
1->north 2->north
a=1 b=2 0:a 1:b
Reading[zone=north, kind=humidity, value=61.0, hour=6]
stop at north

each returns the receiver and cannot be interrupted with break (a return inside the closure only ends that one iteration, see Closures). To stop early use find, any, every, takeWhile or a for loop, as the last lines show. For iteration over ranges, times, upto and step, see Control Structures.

Filtering and searching

find returns the first matching element (or null), findAll all of them, and findResult/findResults combine filtering with transformation by keeping the non-null results of the closure. any, every and count answer yes/no and how-many questions, max and min accept a closure (a one-parameter closure is a key extractor, a two-parameter one a comparator), and findIndexOf, findLastIndexOf and findIndexValues return positions.

println readings.find { it.kind == 'humidity' }
println readings.findAll { it.kind == 'temp' && it.value > 24 }*.value
println readings.findIndexOf { it.zone == 'south' }
println readings.findLastIndexOf { it.zone == 'south' }
println readings.findIndexValues { it.hour == 6 }
println readings.any { it.value > 30 }
println readings.every { it.value > 30 }
println readings.count { it.zone == 'east' }
println readings.max { it.value }.zone
println readings.min { it.value }.zone
println readings*.value.max()
println readings.find { it.value > 100 }            // no match gives null
println readings.findResult { it.value > 30 ? it.zone : null }
println readings.findResults { it.kind == 'humidity' ? it.value : null }
Reading[zone=north, kind=humidity, value=61.0, hour=6]
[28.5, 31.2]
3
5
[0, 2, 3, 6]
true
false
3
east
east
70.5
null
north
[61.0, 48.0, 70.5]

grep filters with any object that has an isCase method, which is the same protocol as switch: a class, a regular expression, a range, a collection or a closure. split divides a collection into the matching and non-matching parts, and take, drop, takeWhile, dropWhile, first, last, head, tail and init slice a list without index arithmetic.

def temps = readings.findAll { it.kind == 'temp' }*.value
println temps
println(temps.grep(19..23))                     // an int range never contains a Double
println(temps.grep { it >= 19 && it < 23 })      // any closure works as the filter
println(['n1', 's2', 'e3', 'x'].grep(~/[nse]\d/))
println([1, 'a', 2.5, null, 3].grep(Number))
println([1, 'a', null, 0, ''].grep())           // no argument: keep the Groovy-true elements
println temps.split { it > 22 }                // two lists: matching and the rest
println temps.take(2)
println temps.drop(5)
println temps.takeWhile { it < 30 }
println temps.dropWhile { it < 30 }
println temps.takeRight(2)
println([temps.first(), temps.last(), temps.head(), temps.tail().size(), temps.init().size()])
println temps[2..4]
println temps[-2..-1]
println temps.getAt([0, 2, 4])
println(temps.indexOf(31.2d))
println(temps.contains(19.0d))
println(temps.containsAll([19.0d, 24.0d]))
println(temps.contains(19.0))                   // a BigDecimal literal is not equal to a Double
println temps.sum()
println temps.disjoint([1.0, 2.0])
[21.5, 24.0, 28.5, 31.2, 19.0, 22.4]
[]
[21.5, 19.0, 22.4]
[n1, s2, e3]
[1, 2.5, 3]
[1, a]
[[24.0, 28.5, 31.2, 22.4], [21.5, 19.0]]
[21.5, 24.0]
[22.4]
[21.5, 24.0, 28.5]
[31.2, 19.0, 22.4]
[19.0, 22.4]
[21.5, 22.4, 21.5, 5, 5]
[28.5, 31.2, 19.0]
[19.0, 22.4]
[21.5, 28.5, 19.0]
3
true
true
false
146.6
true

Two pitfalls appear in this output. An int range such as 19..23 does not contain a Double such as 21.5, so grep(19..23) returns an empty list: use a closure with comparisons for decimals (a decimal range steps by one and is rarely what you want, see Ranges). And contains and indexOf use equals, so the BigDecimal literal 19.0 is not equal to the Double 19.0 (19.0d); this is the number-type behaviour described on Numbers & Numeric Literals.

Transforming

collect maps every element through a closure and returns a list; with an initial collection as the first argument it collects into that instead (a Set, a TreeMap). collectMany maps and flattens, collectEntries builds a map from pairs, flatten flattens nested lists, and withIndex()/indexed() pair each element with its position. The spread operator *. is shorthand for a collect on a property or method, and +, - and intersect combine lists.

println readings.collect { it.value }.take(3)
println readings*.zone.unique()                           // spread operator is a collect on a property
println readings.collect { [it.zone, it.value] }.take(2)
println readings.collectMany { it.kind == 'temp' ? [it.zone] * 2 : [] }.take(4)
println readings.collectEntries { [(it.zone + it.hour + it.kind[0]): it.value] }.take(3)
println readings.take(3).collectEntries { r -> [r.zone, r.value] }
println readings.take(3).collect(new HashSet()) { it.zone }
println readings.take(2).collectEntries(new TreeMap()) { [it.value, it.zone] }
println([[1, [2, 3]], [4]].flatten())
println([[1, 2], [3]].sum())                               // concatenation by plus
println(['a', 'b', 'c'].withIndex())
println(['a', 'b', 'c'].indexed())
println(['a', 'b', 'c'].indexed(10))
println(['a', 'b'] + ['c'] - ['a'])
println([1, 2, 3, 4] - [2, 3])
println([1, 2, 3].intersect([2, 3, 4]))
println([3, 1, 2].reverse())
println([1, 2, 3].join(' | '))
println([[1, 2], [3, 4]].collect { a, b -> a + b })
println([1, 2, 3].collect { it * 2 } as Set)
[21.5, 24.0, 61.0]
[north, south, east]
[[north, 21.5], [north, 24.0]]
[north, north, north, north]
[north6t:21.5, north9t:24.0, north6h:61.0]
[north:61.0]
[north]
[21.5:north, 24.0:north]
[1, 2, 3, 4]
[1, 2, 3]
[[a, 0], [b, 1], [c, 2]]
[0:a, 1:b, 2:c]
[10:a, 11:b, 12:c]
[b, c]
[1, 4]
[2, 3]
[2, 1, 3]
1 | 2 | 3
[3, 7]
[2, 4, 6]

collectEntries accepts a closure returning a two-element list [key, value] or a single-entry map [(key): value]; later entries replace earlier ones with the same key. Because it returns a LinkedHashMap, insertion order is kept. Like collect, it also takes a target map as its first argument.

Aggregating

inject is Groovy’s fold: it threads an accumulator through the elements, starting from the seed you give it or, without a seed, from the first element. sum is the common special case (with an optional closure and an optional initial value; an empty collection sums to null unless you pass a seed), max/min pick the extreme, and average computes the mean of numbers. countBy counts elements per key, groupBy collects them per key and accepts several closures for nested grouping, and injectAll (5.0) is like inject but returns every intermediate result.

def temps = readings.findAll { it.kind == 'temp' }
println temps.inject(0) { acc, r -> acc + r.value }
println temps.inject { a, b -> a.value > b.value ? a : b }.zone     // no seed: first element is the seed
println temps*.value.sum()
println temps.sum { it.value }
println temps.sum { it.value } / temps.size()
println readings.sum(0) { it.hour }
println([].sum())
println([].sum(0))
println temps*.value.max()
println temps.max { it.value }.zone
println readings.countBy { it.zone }
println readings.countBy { it.kind }
println readings.groupBy { it.zone }.collectEntries { zone, rs -> [zone, rs.size()] }
println readings.groupBy({ it.zone }, { it.kind }).collectEntries { z, byKind -> [z, byKind.keySet()] }
println readings.groupBy({ it.zone }, { it.kind }).north.temp*.value
println temps*.value.injectAll(0) { a, b -> a + b }
println([3, 1, 2].inject([]) { acc, n -> acc << n * 10 })
println readings.groupBy { it.hour }.collectEntries { h, rs -> [h, rs*.value.sum()] }
println temps.collect { it.value }.average()
146.6
south
146.6
146.6
24.433333333333334
69
null
0
31.2
south
[north:3, south:3, east:3]
[temp:6, humidity:3]
[north:3, south:3, east:3]
[north:[temp, humidity], south:[temp, humidity], east:[temp, humidity]]
[21.5, 24.0]
[21.5, 45.5, 74.0, 105.2, 124.2, 146.6]
[30, 10, 20]
[6:130.0, 9:196.1]
24.433333333333334

Note that [].sum() is null while [].sum(0) is 0, so pass a seed when the collection can be empty. injectAll returns the running totals, and groupBy with two closures returns a map of maps. A Map is not Iterable, so sum does not exist on it; use values().sum() (a later example shows which methods maps do have).

groupByMany (6.0)

groupByMany is groupBy for items that belong to several groups: the closure returns a list of keys and the item is added to the group of each. It fits many-to-many data such as tags, and on a map whose values are lists the no-argument form inverts the relation (GROOVY-11808).

println(['ant', 'bee', 'ape', 'cow', 'pig'].groupByMany { w -> w.toSet().intersect('aeiou'.toSet()) })
println(readings.groupByMany { [it.zone, it.kind] }.collectEntries { k, rs -> [k, rs.size()] }.sort())
println([north: ['frost', 'wind'], south: ['wind'], east: []].groupByMany())
[a:[ant, ape], e:[bee, ape], i:[pig], o:[cow]]
[east:3, humidity:3, north:3, south:3, temp:6]
[frost:[north], wind:[north, south]]

Sorting

sort() sorts a list in place and also returns it; sort(false) and toSorted() leave the receiver alone and return a new list. A one-parameter closure is a key (sort by -it.value for descending), a two-parameter closure or a Comparator is a comparison; the spaceship operator <=> and the Elvis operator chain several keys, and Comparator.comparing(...) works too. Since 6.0 isSorted() checks order without sorting (GROOVY-11891), with natural ordering, a Comparator or a closure.

def temps = readings.findAll { it.kind == 'temp' }
def values = temps*.value

def copy = values.toSorted()                  // new list, receiver untouched (GDK 2.4+)
println(copy)
println(values)
def same = values.sort()                      // sorts values in place AND returns it
println(same.is(values))
println(values)
println(temps*.value.sort(false))             // sort(false) returns a sorted copy
println(temps.sort { -it.value }*.zone)       // a closure of one parameter is a key
println(temps.toSorted { a, b -> a.zone <=> b.zone ?: b.value <=> a.value }.collect { "$it.zone $it.value" })
println(temps.toSorted(Comparator.comparing { Reading r -> r.zone }.thenComparing { Reading r -> r.value }).collect { "$it.zone $it.value" })
println(readings.sort(false) { it.hour }*.hour)
println(['bb', 'a', 'ccc'].sort { it.size() })
println(['bb', 'a', 'ccc'].toSorted(Comparator.reverseOrder()))
println(values.isSorted())
println(values.isSorted { a, b -> b <=> a })
println([3, 1, 2].isSorted())
println([1, 2, 2, 3].isSorted())
println(['a', 'B', 'c'].toSorted(String.CASE_INSENSITIVE_ORDER))
println([3, 1, 2].max())
println(['kiwi', 'fig', 'apple'].max { it.size() })
println([a: 3, b: 1].sort { it.value })
[19.0, 21.5, 22.4, 24.0, 28.5, 31.2]
[21.5, 24.0, 28.5, 31.2, 19.0, 22.4]
true
[19.0, 21.5, 22.4, 24.0, 28.5, 31.2]
[19.0, 21.5, 22.4, 24.0, 28.5, 31.2]
[south, south, north, east, north, east]
[east 22.4, east 19.0, north 24.0, north 21.5, south 31.2, south 28.5]
[east 19.0, east 22.4, north 21.5, north 24.0, south 28.5, south 31.2]
[6, 6, 6, 6, 9, 9, 9, 9, 9]
[a, bb, ccc]
[ccc, bb, a]
true
false
false
true
[a, B, c]
3
apple
[b:1, a:3]

The first line shows toSorted() leaving values unchanged and the following lines show sort() changing it and returning the very same list (same.is(values) is true). Sorting a Map with sort { } returns a new LinkedHashMap ordered by the closure.

Unique, chunks, combinations and zips

This group reshapes sequences. unique() removes duplicates in place, toUnique() returns a new list, and both accept a closure that defines "the same". collate(n) splits a list into chunks of size n (with a step for overlapping windows and a flag to drop a short tail), chop(sizes...) splits at explicit sizes, transpose() turns rows into columns, and zip/zipAll/interleave (5.0) combine two lists element by element. combinations() is the cartesian product of a list of lists, permutations() all orderings, subsequences() all non-empty ordered subsets, and tails()/inits() all suffixes and prefixes.

println([1, 2, 2, 3, 1].unique())              // unique() mutates the list in place
def zones = readings*.zone
println(zones.toUnique())                      // toUnique() returns a new list
println(['a', 'B', 'A', 'b'].toUnique { it.toLowerCase() })
println((1..7).collate(3))
println((1..7).collate(3, false))              // do not keep the short last chunk
println((1..7).collate(3, 2))                  // step 2: overlapping windows
println((1..7).chop(2, 3))                     // chop: explicit chunk sizes
println([[1, 2], [3, 4], [5, 6]].transpose())
println([1, 2, 3].zip(['a', 'b', 'c']))
println([1, 2, 3].zip(['a', 'b']))
println([1, 2, 3].zipAll(['a'], 0, '-'))
println([1, 2, 3].interleave(['a', 'b', 'c']))
println([1, 2].combinations())                 // needs a list of lists, see below
println([[1, 2], ['a', 'b']].combinations())
println([['n', 's'], [6, 9]].combinations { z, h -> "$z$h" })
println([1, 2, 3].permutations().toList().sort())
println([1, 2, 3].permutations { it.join() }.toList().sort())
println([1, 2, 3, 4].subsequences().size())
println([1, 2, 3].tails())
println([1, 2, 3].inits())
println(['x'].repeat(3))
println([1, 2] * 2)
[1, 2, 3]
[north, south, east]
[a, B]
[[1, 2, 3], [4, 5, 6], [7]]
[[1, 2, 3], [4, 5, 6]]
[[1, 2, 3], [3, 4, 5], [5, 6, 7], [7]]
[[1, 2], [3, 4, 5]]
[[1, 3, 5], [2, 4, 6]]
[[1, a], [2, b], [3, c]]
[[1, a], [2, b]]
[[1, a], [2, -], [3, -]]
[1, a, 2, b, 3, c]
[[1, 2]]
[[1, a], [2, a], [1, b], [2, b]]
[n6, s6, n9, s9]
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
[123, 132, 213, 231, 312, 321]
15
[[1, 2, 3], [2, 3], [3], []]
[[1, 2, 3], [1, 2], [1], []]
[x, x, x]
[1, 2, 1, 2]

permutations() returns a Set of lists and the number of results grows factorially, so the call is only suitable for small inputs. eachPermutation and eachCombination visit the results without building the whole collection.

New in 6.0: neighbours, runs and groupings

Groovy 6.0 adds zipWithNext and groupConsecutive (GROOVY-12016) for questions about neighbouring elements. zipWithNext pairs each element with its successor, optionally combining the pair with a closure, which is the idiom for deltas. groupConsecutive splits an iterable into maximal runs, by equality, by a key or by an adjacency predicate; unlike groupBy, only consecutive equal keys group together. Both have lazy Iterator variants and array overloads. isSorted() was shown above and groupByMany earlier.

println([21.5, 24.0, 28.5].isSorted())
println([21.5, 28.5, 24.0].isSorted())
println([10, 13, 12, 18].zipWithNext())
println([10, 13, 12, 18].zipWithNext { a, b -> b - a })
println([1, 1, 2, 2, 2, 3, 1].groupConsecutive())
println(readings.groupConsecutive { it.zone }.collect { run -> "${run[0].zone}:${run.size()}" })
println(readings*.hour.groupConsecutive { a, b -> b >= a }.size())
true
false
[[10, 13], [13, 12], [12, 18]]
[3, -1, 6]
[[1, 1], [2, 2, 2], [3], [1]]
[north:3, south:3, east:3]
3

Lazy iterators

The collection methods above build a new list at each step. For long or unbounded sequences the Iterator receiver has lazy variants: collecting, findingAll, findingResults, grepping (6.0), collate, takeWhile, drop, unique, withIndex and others return another Iterator that computes elements only when the consumer asks for them, like the intermediate operations of a Java stream. A terminal call (toList(), take(n).toList(), each, sum) runs the pipeline. Groovy 5.0 widened this set considerably.

def temps = readings.findAll { it.kind == 'temp' }.iterator()
def lazy = temps.collecting { it.value }.findingAll { it > 22 }    // nothing evaluated yet
println(lazy instanceof Iterator)
println(lazy.toList())

// an infinite sequence: only lazy methods can be applied before limiting it
def evens = (1..Integer.MAX_VALUE).iterator().findingAll { it % 2 == 0 }
println(evens.take(3).toList())
def pairs = [1, 3, 6, 10, 15].iterator().collate(2)
println(pairs.toList())
println(['row'].repeat().take(3).join('-'))
def seen = []
def mapped = (1..5).iterator().collecting { seen << it; it * it }
println(seen)                                        // empty: still lazy
println(mapped.take(2).toList())
println(seen)                                        // only two elements were pulled
println([1, 0, null, 2].iterator().grepping().toList())
true
[24.0, 28.5, 31.2, 22.4]
[2, 4, 6]
[[1, 3], [6, 10], [15]]
row-row-row
[]
[1, 4]
[1, 2]
[1, 2]

seen stays empty until the pipeline is consumed and receives only the two elements that take(2) pulled, which is the laziness at work. A lazy iterator is single use: once consumed it cannot be replayed, so call toList() if the result is needed more than once.

Streams interoperability

The JDK streams API is available as usual, and Groovy makes it more convenient: a closure is coerced to the functional interface a stream method expects (Predicate, Function, ToDoubleFunction), Stream.toList() is the JDK’s, and 6.0 adds toMutableList(), toImmutableList() and toImmutableSet() as explicit-mutability accumulators for streams and iterables (GROOVY-12037). Where a method needs a specific functional type that Groovy cannot infer from the context, add as Function (or the matching interface), as in the groupingBy call below.

import java.util.stream.Collectors

def temps = readings.findAll { it.kind == 'temp' }
println(temps.stream().mapToDouble { it.value }.max().asDouble)
println(temps.stream().filter { it.zone == 'north' }.map { it.value }.toList())     // JDK 16+ Stream.toList()
println(temps.stream().collect(Collectors.groupingBy({ it.zone } as java.util.function.Function, Collectors.counting())).sort())
println(temps.stream().map { it.zone }.distinct().collect(Collectors.joining(',', '[', ']')))
def mutable = temps.stream().map { it.value }.toMutableList()                      // 6.0
mutable << 99.9d
println(mutable.size())
def fixed = temps.stream().map { it.value }.toImmutableList()
try { fixed << 99.9d } catch (UnsupportedOperationException e) { println 'immutable' }
println(temps.stream().map { it.zone }.toImmutableSet().size())
def asList = (1..3).stream().collect(Collectors.toList())
println(asList)
println(Arrays.asList(3, 1, 2).stream().sorted().toList())
println(temps.collect { it.value }.sum())                                           // the same without streams
println((1..4).stream().mapToInt { it }.sum())
println([1, 2, 3].stream().map { it * 2 }.collect(Collectors.toList()))
println(temps.parallelStream().mapToDouble { it.value }.sum())
31.2
[21.5, 24.0]
[east:2, north:2, south:2]
[north,south,east]
7
immutable
3
[1, 2, 3]
[1, 2, 3]
146.6
10
[2, 4, 6]
146.6

Most stream pipelines can be written with the GDK methods directly, with less ceremony, and collect, findAll, groupBy and friends now also have overloads for java.util.function types that avoid the per-call closure allocation (see Functional Programming). Reach for streams when you need their parallelism, a Collector that has no GDK equivalent, or an API that returns a Stream.

GPath over collections

GPath is the name for navigating nested data with property access. On a collection, a property access applies to every element and returns a list, so readings.zone is the same as readings*.zone; *. additionally works for method calls and is null-safe for the list itself. Chaining property accesses digs through nested maps and lists, and the result of a missing property on the elements is an error, not an empty result.

println(readings*.zone.unique())                       // spread-dot
println(readings.zone.unique())                        // property on a list: same GPath, implicit spread
println(readings.findAll { it.kind == 'temp' }.value)
println(readings*.value.take(3))
println(readings.hour.sum())
println(readings*.toString().take(1))
try { readings.nonexistent } catch (MissingPropertyException e) { println e.class.simpleName }
def tree = [zone: [name: 'north', beds: [[id: 'b1', plants: 12], [id: 'b2', plants: 7]]]]
println(tree.zone.beds.id)
println(tree.zone.beds*.plants.sum())
println(tree.zone.beds.find { it.plants > 10 }.id)
println(tree.'zone'.'beds'[1].id)
println(tree.zone.beds.collect { it.plants }.max())
println(null*.size())
println([null, 'ab']*.size())
println([[1, 2], [3]]*.size())
println(tree.zone.beds.size())
println(tree.zone.beds*.id.join('/'))
[north, south, east]
[north, south, east]
[21.5, 24.0, 28.5, 31.2, 19.0, 22.4]
[21.5, 24.0, 61.0]
69
[Reading[zone=north, kind=temp, value=21.5, hour=6]]
MissingPropertyException
[b1, b2]
19
b1
b2
12
null
[null, 2]
[2, 1]
2
b1/b2

The same navigation is used with the data formats: see JSON and XML Parsing, GPath & Security.

Methods added in Groovy 5.0

Groovy 5.0 added about 350 extension methods, many of them for collections, iterators and arrays. The most useful ones: repeat, zip/zipAll/interleave, withCollectedKeys/withCollectedValues, collectKeys/collectValues on maps, flattenMany, injectAll, set operators (union, &, |, ^), partitionPoint for sorted data, and array versions of chop, indexed, collect and others.

// Groovy 5.0 additions
println(['row'].repeat(3) + ['your', 'boat'])
println(['na'].repeat().take(5).join('-'))
println(['a', 'b', 'c'].zip([1, 2, 3]).collectEntries())
println(['Groovy', 'Java', 'Kotlin'].withCollectedKeys { it.take(1) })
println(['Groovy', 'Java', 'Kotlin'].withCollectedValues { it.size() })
println([Groovy: 6, Java: 4].collectKeys { it.toLowerCase() })
println([Groovy: 6, Java: 4].collectValues { it * 2 })
println(['1', '2', 'foo', '3'].flattenMany { it.isNumber() ? Optional.of(it.toInteger()) : Optional.empty() })
println(readings*.hour.injectAll(0) { acc, h -> acc + h })
println(['a', 'b'].interleave([1, 2, 3]))
println(['a', 'b'].interleave([1, 2, 3], true))
def a = [1, 2, 3] as Set
def b = [2, 3, 4] as Set
println(a.union(b)); println(a & b); println(a | b); println(a ^ b)
println([1, 3, 5, 7].partitionPoint { it < 4 })
println(['a', 'b', 'c'].indexed().collect { i, s -> "$i$s" })
println(([5, 6, 7, 8] as int[]).collect { it * 2 })
println(([5, 6, 7, 8] as int[]).chop(2, 2))
println(([5, 6, 7, 8] as int[]).indexed())
println((1..10).chop(3, 3, 4))
println(readings.collectEntries { r -> [(r.zone): r.value] }.keySet().sort())
[row, row, row, your, boat]
na-na-na-na-na
[a:1, b:2, c:3]
[G:Groovy, J:Java, K:Kotlin]
[Groovy:6, Java:4, Kotlin:6]
[groovy:6, java:4]
[Groovy:12, Java:8]
[1, 2, 3]
[6, 15, 21, 27, 36, 45, 51, 60, 69]
[a, 1, b, 2]
[a, 1, b, 2, 3]
[1, 2, 3, 4]
[2, 3]
[1, 2, 3, 4]
[1, 4]
2
[0a, 1b, 2c]
[10, 12, 14, 16]
[[5, 6], [7, 8]]
[0:5, 1:6, 2:7, 3:8]
[[1, 2, 3], [4, 5, 6], [7, 8, 9, 10]]
[east, north, south]

Putting it together: averages per zone

The pipeline from the diagram, written as a chain of GDK calls. The assertion states the result so the example documents itself, and a second version shows the same query as a statically compiled method with explicit types, which is how the code would look in a class that is compiled with @CompileStatic (see Static Type Checking & @CompileStatic).

def averageTempByZone = readings
    .findAll { it.kind == 'temp' }                                  // 1. keep temperature readings
    .collect { [zone: it.zone, celsius: it.value] }                  // 2. keep only what is needed
    .groupBy { it.zone }                                             // 3. zone -> list of readings
    .collectEntries { zone, rs ->                                    // 4. zone -> average
        [zone, (rs*.celsius.sum() / rs.size()).round(2)]
    }

println averageTempByZone
assert averageTempByZone == [north: 22.75, south: 29.85, east: 20.7]

// the same query as a typed, statically compiled method
@groovy.transform.CompileStatic
static Map<String, BigDecimal> averages(List<Reading> rs) {
    rs.findAll { it.kind == 'temp' }
      .groupBy { it.zone }
      .collectEntries { String zone, List<Reading> zs ->
          [zone, (zs.sum { it.value } as BigDecimal) / zs.size()]
      } as Map<String, BigDecimal>
}
println averages(readings)
[north:22.75, south:29.85, east:20.7]
[north:22.75, south:29.85, east:20.7]

Chaining works because each call returns a new collection. The cost is one intermediate collection per step; for large data use the lazy iterator methods above, or a stream, so that the data is traversed once.

Copy or modify, and what a Map does differently

Most mistakes with collections come from not knowing whether a call mutates. The summary: sort, unique, reverse (with a true flag), add, remove, removeAll, retainAll, removeIf and << modify the receiver; toSorted, toUnique, findAll, collect, +, - and plus build a new collection. Mutating a collection while iterating it with each throws ConcurrentModificationException for the JDK collections; use removeIf, an explicit Iterator, or build a new list. The copy semantics and the immutability helpers are covered in more depth on Lists & Arrays.

def temps = [21.5, 24.0, 19.0]
def sorted = temps.sort()               // surprise: temps is now sorted too
println temps.is(sorted)
def original = [3, 1, 2]
def copy = original.toSorted()
println original
println copy

def m = [a: 1, b: 2, c: 3]
println m.findAll { it.value > 1 }
println m.findAll { it.value > 1 }.getClass().simpleName
println m.collect { it.value }
println m.collectEntries { k, v -> [v, k] }
println m.find { it.value > 1 }
println m.count { k, v -> v > 1 }
println m.groupBy { it.value % 2 }
println m.sort { -it.value }
println m.max { it.value }
println m.values().sum()                   // a Map is not Iterable: no sum, max-by-closure on entries only
println m.any { k, v -> v == 3 }

def list = [1, 2, 3, 4]
try {
    list.each { if (it == 2) list.remove(it) }
} catch (ConcurrentModificationException e) {
    println e.class.simpleName
}
println list                                  // [1, 2, 4]: remove(2) removed the element at index 2
list.removeIf { it % 2 == 0 }                 // the JDK way: mutate through the API
println list
println list.findAll { it != 1 }               // or build a new list
true
[3, 1, 2]
[1, 2, 3]
[b:2, c:3]
LinkedHashMap
[1, 2, 3]
[1:a, 2:b, 3:c]
b=2
2
[1:[a:1, c:3], 0:[b:2]]
[c:3, b:2, a:1]
c=3
6
true
ConcurrentModificationException
[1, 2, 4]
[1]
[]

Notes on the output:

  • sort() changes temps as well as returning it, so temps.is(sorted) is true.

  • On a map, findAll returns a map but collect returns a list; find returns a Map.Entry; groupBy returns a map of maps; sort returns a new ordered map. See Maps.

  • The ConcurrentModificationException is thrown and list.remove(it) is a trap in itself: for an Integer argument Groovy calls remove(int index), not remove(Object), so the list lost its third element (3), not the 2 that was meant. The result [1, 2, 4] is printed after the failure for that reason.

Where the book is outdated

The Manning book Groovy in Action (publisher page in the references) devotes a chapter to collections, and its description of each, collect, find, inject, sort and groupBy still holds. Check these points against the current documentation:

  • The collection methods grew a lot: toSorted/toUnique (2.4), lazy iterator methods, zip, repeat, flattenMany, injectAll and the set operators (5.0), groupByMany, isSorted, zipWithNext and groupConsecutive (6.0).

  • The java.util.function overloads (6.0), the Stream accumulators and groovyToString formatting of collections did not exist when the book was written.

  • The concurrent-modification and copy-versus-modify discussions apply unchanged, but modern code prefers removeIf and the non-mutating toSorted and findAll.

  • Baselines: Groovy 6.0 needs JDK 17 or later.