Complete Groovy collections overview covering lists, maps, sets, and ranges with 12 tested examples. Master collect, findAll, inject, groupBy on Groovy 5.x.
“The collection framework is the backbone of any language. Master collections, and you master data manipulation.”
Joshua Bloch, Effective Java
Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate | Reading Time: 22 minutes
If you’ve been writing Groovy for any amount of time, you already know that Groovy collections are where the language truly shines. Lists, maps, sets, and ranges aren’t just data containers in Groovy – they come packed with useful methods that let you transform, filter, group, and aggregate data in ways that would take ten times the code in plain Java.
This post is your complete Groovy collections overview. We’ll look at all four collection types side by side, compare when to use each one, and then walk through 12 essential methods that work across all of them. If you’ve already read our deep-dive posts on Groovy Lists, Groovy Maps, Groovy Sets, or Groovy Ranges, this post ties everything together.
We’ll cover which collection to pick for any given task, how to chain collection methods like a pro, and the performance trade-offs that matter in real projects. Every example is tested on Groovy 5.x, so you can copy-paste and run them right away.
Table of Contents
What Are Groovy Collections?
Groovy collections are built on top of Java’s java.util.Collection framework, but Groovy extends them with the GDK (Groovy Development Kit). According to the official Groovy documentation on collections, Groovy adds over 100 convenience methods to standard Java collection classes.
What makes Groovy data structures special compared to Java is threefold:
- Literal syntax – Create lists with
[], maps with[:], sets with[] as Set, and ranges with1..10 - GDK methods – Methods like
collect(),findAll(),groupBy(), andinject()added to all collection types - Operator overloading – Use
+,-,*,<<, and subscript operators with collections naturally - Closure support – Every iteration and transformation method takes a closure, making functional-style programming effortless
The 4 Core Collection Types
Lists
An ordered collection that allows duplicates. Under the hood, Groovy lists are java.util.ArrayList by default. They’re the most commonly used collection type and support indexed access. For more details, see our complete Groovy List tutorial.
List Basics
def fruits = ['Apple', 'Banana', 'Cherry', 'Apple']
println "Type: ${fruits.getClass().name}"
println "Size: ${fruits.size()}"
println "First: ${fruits[0]}"
println "Last: ${fruits[-1]}"
println "Duplicates allowed: ${fruits.count('Apple')}"
Output
Type: java.util.ArrayList Size: 4 First: Apple Last: Apple Duplicates allowed: 2
Maps
A collection of key-value pairs. Default implementation is java.util.LinkedHashMap, which preserves insertion order. Maps are perfect for configuration data, lookups, and structured records. Check out our complete Groovy Map guide for more.
Map Basics
def person = [name: 'Alice', age: 30, city: 'London']
println "Type: ${person.getClass().name}"
println "Size: ${person.size()}"
println "Name: ${person.name}"
println "Keys: ${person.keySet()}"
println "Values: ${person.values()}"
Output
Type: java.util.LinkedHashMap Size: 3 Name: Alice Keys: [name, age, city] Values: [Alice, 30, London]
Sets
An unordered collection with no duplicates. Default implementation is java.util.LinkedHashSet. Sets are ideal when you need unique values and fast membership testing. See our Groovy Set examples for the full story.
Set Basics
def colors = ['Red', 'Green', 'Blue', 'Red', 'Green'] as Set
println "Type: ${colors.getClass().name}"
println "Size: ${colors.size()}"
println "Contains Red: ${colors.contains('Red')}"
println "Duplicates removed: ${colors}"
Output
Type: java.util.LinkedHashSet Size: 3 Contains Red: true Duplicates removed: [Red, Green, Blue]
Ranges
A sequence of values defined by a start and an end. Ranges implement java.util.List, so they support all list operations. They’re memory-efficient because they don’t store every element. For more, see our Groovy Range examples post.
Range Basics
def nums = 1..5
def letters = 'a'..'f'
def exclusive = 1..<5
println "Type: ${nums.getClass().name}"
println "Numbers: ${nums}"
println "Letters: ${letters}"
println "Exclusive: ${exclusive}"
println "Size: ${nums.size()}"
println "Contains 3: ${nums.contains(3)}"
Output
Type: groovy.lang.IntRange Numbers: [1, 2, 3, 4, 5] Letters: [a, b, c, d, e, f] Exclusive: [1, 2, 3, 4] Size: 5 Contains 3: true
Collection Type Comparison
Here’s a side-by-side comparison of all four Groovy collection types to help you pick the right one:
| Feature | List | Map | Set | Range |
|---|---|---|---|---|
| Ordered | Yes | Yes (LinkedHashMap) | No (HashSet) / Yes (LinkedHashSet) | Yes |
| Duplicates | Allowed | Keys unique | No duplicates | N/A |
| Indexed access | Yes (by position) | Yes (by key) | No | Yes (by position) |
| Literal syntax | [] | [:] | [] as Set | a..b |
| Default Java type | ArrayList | LinkedHashMap | LinkedHashSet | IntRange / ObjectRange |
| Best for | Ordered data, queues | Key-value lookups | Unique values, membership | Sequences, loops |
| Null values | Yes | Yes (keys and values) | One null allowed | No |
All four types share the common GDK collection methods we’ll cover next. That’s one of the most powerful aspects of Groovy collections – you learn methods like collect(), findAll(), and each() once, and they work everywhere.
12 Essential Collection Methods with Examples
These methods work across lists, maps, sets, and ranges. They’re the core toolkit for data manipulation in Groovy. Let’s walk through each one with tested examples.
Example 1: each() – Iterate Over Elements
What we’re doing: Using each() to iterate over every element in a collection. This is Groovy’s replacement for Java’s for-each loop.
Example 1: each()
// each on a List
def languages = ['Groovy', 'Java', 'Kotlin', 'Scala']
print "Languages: "
languages.each { print "${it} " }
println()
// each on a Map (key, value)
def scores = [Alice: 95, Bob: 82, Charlie: 91]
scores.each { name, score ->
println " ${name} scored ${score}"
}
// eachWithIndex on a List
languages.eachWithIndex { lang, idx ->
print "${idx}:${lang} "
}
println()
// each on a Range
(1..5).each { print "${it * it} " }
println()
Output
Languages: Groovy Java Kotlin Scala Alice scored 95 Bob scored 82 Charlie scored 91 0:Groovy 1:Java 2:Kotlin 3:Scala 1 4 9 16 25
What happened here: each() takes a closure and executes it for every element. For maps, the closure receives two parameters (key and value). eachWithIndex() gives you both the element and its position. Note that each() always returns the original collection, not a new one.
Example 2: collect() – Transform Elements
What we’re doing: Using collect() to transform every element and return a new list. This is Groovy’s equivalent of Java Streams’ map().
Example 2: collect()
// Transform list elements
def numbers = [1, 2, 3, 4, 5]
def doubled = numbers.collect { it * 2 }
println "Doubled: ${doubled}"
// Transform map values
def prices = [coffee: 4.50, tea: 3.00, juice: 5.25]
def withTax = prices.collect { item, price ->
"${item}: \$${String.format('%.2f', price * 1.08)}"
}
println "With tax: ${withTax}"
// Collect from a range
def squares = (1..6).collect { it * it }
println "Squares: ${squares}"
// Using the spread operator (* as shortcut)
def names = ['alice', 'bob', 'charlie']
def capitalized = names*.capitalize()
println "Capitalized: ${capitalized}"
Output
Doubled: [2, 4, 6, 8, 10] With tax: [coffee: $4.86, tea: $3.24, juice: $5.67] Squares: [1, 4, 9, 16, 25, 36] Capitalized: [Alice, Bob, Charlie]
What happened here: collect() applies a closure to each element and returns a new list with the results. The original collection is untouched. The spread-dot operator (*.) is a shorthand for collect when you’re just calling a method on each element.
Example 3: findAll() – Filter Elements
What we’re doing: Using findAll() to filter a collection based on a condition. This is like Java Streams’ filter().
Example 3: findAll()
// Filter a list
def numbers = [12, 5, 23, 8, 17, 3, 41, 9]
def bigNumbers = numbers.findAll { it > 10 }
println "Greater than 10: ${bigNumbers}"
// Filter a map
def employees = [
Alice: 95000, Bob: 62000, Charlie: 81000,
Diana: 73000, Eve: 110000
]
def highEarners = employees.findAll { name, salary -> salary >= 80000 }
println "High earners: ${highEarners}"
// Filter a set
def tags = ['groovy', 'java', 'kotlin', 'go', 'rust'] as Set
def jvmLangs = tags.findAll { it in ['groovy', 'java', 'kotlin', 'scala', 'clojure'] }
println "JVM languages: ${jvmLangs}"
// Combine find (first match) vs findAll (all matches)
println "First > 10: ${numbers.find { it > 10 }}"
println "All > 10: ${numbers.findAll { it > 10 }}"
Output
Greater than 10: [12, 23, 17, 41] High earners: [Alice:95000, Charlie:81000, Eve:110000] JVM languages: [groovy, java, kotlin] First > 10: 12 All > 10: [12, 23, 17, 41]
What happened here: findAll() returns a new collection containing only elements where the closure returns true. find() returns just the first matching element (or null if none match). For maps, findAll() returns a filtered map – not a list.
Example 4: any() and every() – Boolean Tests
What we’re doing: Using any() to check if at least one element matches, and every() to check if all elements match.
Example 4: any() and every()
def scores = [88, 92, 76, 95, 81]
// Does any score exceed 90?
println "Any above 90: ${scores.any { it > 90 }}"
// Did everyone pass (above 70)?
println "All passed: ${scores.every { it >= 70 }}"
// Did everyone get an A (above 90)?
println "All got A: ${scores.every { it > 90 }}"
// Works on maps too
def inventory = [apples: 12, bananas: 0, oranges: 8, grapes: 3]
println "Any out of stock: ${inventory.any { item, qty -> qty == 0 }}"
println "All in stock: ${inventory.every { item, qty -> qty > 0 }}"
// Works on ranges
println "Any even in 1..5: ${(1..5).any { it % 2 == 0 }}"
println "All positive in 1..5: ${(1..5).every { it > 0 }}"
Output
Any above 90: true All passed: true All got A: false Any out of stock: true All in stock: false Any even in 1..5: true All positive in 1..5: true
What happened here: any() returns true if the closure returns true for at least one element. every() returns true only when the closure returns true for every element. Both short-circuit – they stop as soon as the result is determined.
Example 5: inject() / fold – Reduce to a Single Value
What we’re doing: Using inject() (also called fold) to reduce a collection to a single accumulated value. This is Groovy’s version of reduce() from Java Streams.
Example 5: inject()
// Sum numbers
def numbers = [10, 20, 30, 40, 50]
def total = numbers.inject(0) { acc, val -> acc + val }
println "Sum: ${total}"
// Build a string from a list
def words = ['Groovy', 'is', 'awesome']
def sentence = words.inject('') { acc, word ->
acc ? "${acc} ${word}" : word
}
println "Sentence: ${sentence}"
// Factorial using a range
def factorial = (1..6).inject(1) { acc, n -> acc * n }
println "6! = ${factorial}"
// Find the longest string
def langs = ['Groovy', 'Go', 'JavaScript', 'Rust', 'Python']
def longest = langs.inject('') { acc, lang ->
lang.length() > acc.length() ? lang : acc
}
println "Longest: ${longest}"
// Flatten a map into a query string
def params = [page: 1, sort: 'name', order: 'asc']
def queryString = params.inject([]) { acc, key, val ->
acc << "${key}=${val}"
}.join('&')
println "Query: ${queryString}"
Output
Sum: 150 Sentence: Groovy is awesome 6! = 720 Longest: JavaScript Query: page=1&sort=name&order=asc
What happened here: inject(initialValue) passes an accumulator and each element to the closure. The closure’s return value becomes the new accumulator. It’s extremely versatile – use it whenever you need to reduce a collection to a single result. Note that Groovy also has shorthand methods like sum() for common reductions.
Example 6: groupBy() – Group Elements
What we’re doing: Using groupBy() to organize collection elements into groups based on a classifier closure.
Example 6: groupBy()
// Group numbers by even/odd
def numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
def grouped = numbers.groupBy { it % 2 == 0 ? 'even' : 'odd' }
println "Grouped: ${grouped}"
// Group words by first letter
def fruits = ['Apple', 'Avocado', 'Banana', 'Blueberry', 'Cherry', 'Cranberry']
def byLetter = fruits.groupBy { it[0] }
println "By letter: ${byLetter}"
// Group employees by department
def employees = [
[name: 'Alice', dept: 'Engineering'],
[name: 'Bob', dept: 'Marketing'],
[name: 'Charlie', dept: 'Engineering'],
[name: 'Diana', dept: 'Marketing'],
[name: 'Eve', dept: 'Engineering']
]
def byDept = employees.groupBy { it.dept }
byDept.each { dept, members ->
println "${dept}: ${members*.name}"
}
Output
Grouped: [odd:[1, 3, 5, 7, 9], even:[2, 4, 6, 8, 10]] By letter: [A:[Apple, Avocado], B:[Banana, Blueberry], C:[Cherry, Cranberry]] Engineering: [Alice, Charlie, Eve] Marketing: [Bob, Diana]
What happened here: groupBy() returns a map where the keys are the classification values and the values are lists of matching elements. It’s incredibly useful for reporting, categorization, and aggregation tasks.
Example 7: countBy() – Count by Category
What we’re doing: Using countBy() to count how many elements fall into each category. Think of it as groupBy() but returning counts instead of the actual elements.
Example 7: countBy()
// Count words by length
def words = ['cat', 'dog', 'elephant', 'ant', 'bear', 'fox', 'giraffe']
def byLength = words.countBy { it.length() }
println "By length: ${byLength}"
// Count characters in a string
def text = 'mississippi'
def charCount = text.toList().countBy { it }
println "Char count: ${charCount}"
// Count numbers by range
def scores = [45, 67, 82, 91, 55, 78, 94, 33, 71, 88]
def grades = scores.countBy { score ->
switch (score) {
case 90..100: return 'A'
case 80..89: return 'B'
case 70..79: return 'C'
case 60..69: return 'D'
default: return 'F'
}
}
println "Grades: ${grades}"
Output
By length: [3:4, 8:1, 4:1, 7:1] Char count: [m:1, i:4, s:4, p:2] Grades: [F:3, D:1, B:2, A:2, C:2]
What happened here: countBy() applies the closure to each element and counts occurrences of each return value. It’s a concise alternative to groupBy { ... }.collectEntries { k, v -> [k, v.size()] }.
Example 8: sort(), min(), max(), and sum()
What we’re doing: Using built-in aggregation and ordering methods that work across all collection types.
Example 8: sort, min, max, sum
def prices = [29.99, 9.99, 49.99, 14.99, 39.99]
println "Sorted: ${prices.sort(false)}"
println "Min: ${prices.min()}"
println "Max: ${prices.max()}"
println "Sum: ${prices.sum()}"
// Sort with a custom comparator
def words = ['banana', 'apple', 'cherry', 'date']
def sortedByLength = words.sort(false) { a, b -> a.length() <=> b.length() }
println "By length: ${sortedByLength}"
// min/max with a closure
def people = [[name: 'Alice', age: 30], [name: 'Bob', age: 25], [name: 'Charlie', age: 35]]
println "Youngest: ${people.min { it.age }.name}"
println "Oldest: ${people.max { it.age }.name}"
// Sum with a closure
def items = [[name: 'Widget', price: 12.50, qty: 3], [name: 'Gadget', price: 24.99, qty: 2]]
def orderTotal = items.sum { it.price * it.qty }
println "Order total: \$${String.format('%.2f', orderTotal)}"
Output
Sorted: [9.99, 14.99, 29.99, 39.99, 49.99] Min: 9.99 Max: 49.99 Sum: 144.95 By length: [date, apple, banana, cherry] Youngest: Bob Oldest: Charlie Order total: $87.48
What happened here: sort(false) returns a new sorted list without modifying the original (pass true or no argument to sort in-place). min(), max(), and sum() all accept optional closures for extracting the comparison or summation value.
Example 9: flatten() and unique()
What we’re doing: Using flatten() to collapse nested collections and unique() to remove duplicates.
Example 9: flatten() and unique()
// Flatten nested lists
def nested = [[1, 2], [3, [4, 5]], [6]]
println "Flattened: ${nested.flatten()}"
// Unique values
def dupes = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
println "Unique: ${dupes.unique(false)}"
// Unique by a property
def people = [
[name: 'Alice', dept: 'Engineering'],
[name: 'Bob', dept: 'Marketing'],
[name: 'Charlie', dept: 'Engineering'],
[name: 'Diana', dept: 'Sales']
]
def uniqueDepts = people.unique(false) { it.dept }
println "One per dept: ${uniqueDepts*.name}"
// Combine flatten + unique
def tagSets = [['groovy', 'java'], ['java', 'kotlin'], ['groovy', 'scala']]
def allTags = tagSets.flatten().unique().sort()
println "All unique tags: ${allTags}"
Output
Flattened: [1, 2, 3, 4, 5, 6] Unique: [3, 1, 4, 5, 9, 2, 6] One per dept: [Alice, Bob, Diana] All unique tags: [groovy, java, kotlin, scala]
What happened here: flatten() recursively unwraps all nested lists into a single flat list. unique(false) returns a new list with duplicates removed (without modifying the original). When you pass a closure to unique(), it determines uniqueness by the closure’s return value.
Example 10: intersect(), plus(), and minus() – Set Operations
What we’re doing: Using set-like operations that work on any collection – intersection, union, and difference.
Example 10: Set Operations
def frontend = ['HTML', 'CSS', 'JavaScript', 'TypeScript']
def backend = ['Java', 'Groovy', 'JavaScript', 'Python']
// Intersection - elements in both
def fullStack = frontend.intersect(backend)
println "Full-stack skills: ${fullStack}"
// Union - combine (use + operator)
def allSkills = (frontend + backend).unique()
println "All skills: ${allSkills}"
// Difference - elements in A but not B (use - operator)
def frontendOnly = frontend - backend
def backendOnly = backend - frontend
println "Frontend only: ${frontendOnly}"
println "Backend only: ${backendOnly}"
// Works with Sets too
def setA = [1, 2, 3, 4, 5] as Set
def setB = [4, 5, 6, 7, 8] as Set
println "A intersect B: ${setA.intersect(setB)}"
println "A + B: ${setA + setB}"
println "A - B: ${setA - setB}"
Output
Full-stack skills: [JavaScript] All skills: [HTML, CSS, JavaScript, TypeScript, Java, Groovy, Python] Frontend only: [HTML, CSS, TypeScript] Backend only: [Java, Groovy, Python] A intersect B: [4, 5] A + B: [1, 2, 3, 4, 5, 6, 7, 8] A - B: [1, 2, 3]
What happened here: Groovy overloads the + and - operators for collections. The + operator concatenates, while - removes matching elements. intersect() returns only elements present in both collections.
Example 11: Spread Operator (*.) – Call Methods on Each Element
What we’re doing: Using the spread-dot operator to invoke a method on every element in a collection in one concise expression.
Example 11: Spread Operator
// Spread on a list
def names = ['alice', 'bob', 'charlie']
println "Upper: ${names*.toUpperCase()}"
println "Lengths: ${names*.length()}"
// Spread on nested properties
def teams = [
[name: 'Alpha', members: ['Alice', 'Bob']],
[name: 'Beta', members: ['Charlie', 'Diana', 'Eve']]
]
println "Team names: ${teams*.name}"
println "Team sizes: ${teams*.members*.size()}"
// Spread with method arguments using spread operator (*)
def ranges = [[1, 5], [10, 20], [100, 200]]
def sums = ranges.collect { args -> (args[0]..args[1]).sum() }
println "Range sums: ${sums}"
// Spread into method call
def values = [3, 1, 4, 1, 5]
println "Max of list: ${[values.max(), values.min(), values.sum()]}"
Output
Upper: [ALICE, BOB, CHARLIE] Lengths: [5, 3, 7] Team names: [Alpha, Beta] Team sizes: [2, 3] Range sums: [15, 165, 15150] Max of list: [5, 1, 14]
What happened here: The spread-dot operator (*.) calls a method or accesses a property on every element and returns a list of results. It’s syntactic sugar for collect { it.method() } and is one of Groovy’s most loved features for concise data extraction.
Example 12: Chaining Methods – Real-World Data Pipeline
What we’re doing: Chaining multiple collection methods together to build a data processing pipeline – the way you’d actually use Groovy collections in production code.
Example 12: Method Chaining Pipeline
// Sales data
def orders = [
[product: 'Laptop', category: 'Electronics', price: 999.99, qty: 2],
[product: 'Mouse', category: 'Electronics', price: 29.99, qty: 5],
[product: 'Desk', category: 'Furniture', price: 349.99, qty: 1],
[product: 'Chair', category: 'Furniture', price: 249.99, qty: 3],
[product: 'Monitor', category: 'Electronics', price: 449.99, qty: 2],
[product: 'Lamp', category: 'Furniture', price: 59.99, qty: 4],
[product: 'Keyboard',category: 'Electronics', price: 79.99, qty: 3]
]
// Pipeline: filter, transform, group, aggregate
def report = orders
.findAll { it.price * it.qty > 200 } // filter: only orders > $200
.collect { it + [total: it.price * it.qty] } // add total field
.groupBy { it.category } // group by category
.collectEntries { category, items -> // aggregate each group
[category, [
count: items.size(),
revenue: items*.total.sum(),
topProduct: items.max { it.total }.product
]]
}
report.each { category, stats ->
println "${category}:"
println " Orders: ${stats.count}"
println " Revenue: \$${String.format('%.2f', stats.revenue)}"
println " Top product: ${stats.topProduct}"
}
// Quick summary
def totalRevenue = orders.sum { it.price * it.qty }
println "\nTotal Revenue: \$${String.format('%.2f', totalRevenue)}"
println "Avg order value: \$${String.format('%.2f', totalRevenue / orders.size())}"
Output
Electronics: Orders: 3 Revenue: $3139.93 Top product: Laptop Furniture: Orders: 3 Revenue: $1339.92 Top product: Chair Total Revenue: $4629.80 Avg order value: $661.40
What happened here: This is where Groovy collections really shine. We chained findAll() to filter, collect() to transform, groupBy() to categorize, and collectEntries() to aggregate – all in a single readable pipeline. No temporary variables, no loops, no mutable state. This is functional-style data processing at its best.
Choosing the Right Collection Type
Picking the right Groovy data structure depends on your use case. Here’s a quick decision guide:
- Need ordered items with index access? Use a List.
def items = [1, 2, 3] - Need key-value lookups? Use a Map.
def config = [timeout: 30, retries: 3] - Need unique values or fast membership testing? Use a Set.
def ids = [101, 102, 103] as Set - Need a numeric or character sequence? Use a Range.
def months = 1..12 - Need a queue (FIFO)? Use
new LinkedList()ornew ArrayDeque() - Need a stack (LIFO)? Use
new ArrayDeque()withpush()andpop()
Choosing the Right Type
// Scenario 1: Processing API response data - use List
def users = [[id: 1, name: 'Alice'], [id: 2, name: 'Bob']]
def activeUsers = users.findAll { it.id > 0 }
// Scenario 2: Configuration/settings - use Map
def dbConfig = [host: 'localhost', port: 5432, db: 'myapp']
println "Connect to: ${dbConfig.host}:${dbConfig.port}"
// Scenario 3: Tracking unique visitors - use Set
def visitors = new HashSet()
visitors.addAll(['user1', 'user2', 'user1', 'user3'])
println "Unique visitors: ${visitors.size()}"
// Scenario 4: Pagination - use Range
def pageSize = 20
def page = 3
def range = ((page - 1) * pageSize)..<(page * pageSize)
println "Fetching records ${range.from} to ${range.to}"
Output
Connect to: localhost:5432 Unique visitors: 3 Fetching records 40 to 60
Immutable vs Mutable Collections
By default, all Groovy collections are mutable. But sometimes you want to guarantee that a collection can’t be changed after creation – especially when passing data between methods or threads.
Immutable vs Mutable Collections
// Mutable (default)
def mutableList = [1, 2, 3]
mutableList << 4
println "Mutable: ${mutableList}"
// Immutable using asImmutable()
def immutableList = [1, 2, 3].asImmutable()
try {
immutableList << 4
} catch (UnsupportedOperationException e) {
println "Cannot modify immutable list: ${e.class.simpleName}"
}
// Immutable using Collections.unmodifiable*
def frozenMap = Collections.unmodifiableMap([name: 'Alice', role: 'Admin'])
try {
frozenMap.name = 'Bob'
} catch (UnsupportedOperationException e) {
println "Cannot modify frozen map: ${e.class.simpleName}"
}
// asUnmodifiable() - Groovy 3+
def readOnly = ['a', 'b', 'c'].asUnmodifiable()
println "Read-only list: ${readOnly}"
// You can still create a new collection from an immutable one
def extended = immutableList + [4, 5]
println "New list from immutable: ${extended}"
Output
Mutable: [1, 2, 3, 4] Cannot modify immutable list: UnsupportedOperationException Cannot modify frozen map: UnsupportedOperationException Read-only list: [a, b, c] New list from immutable: [1, 2, 3, 4, 5]
The key difference: asImmutable() and asUnmodifiable() both prevent modifications to the collection itself, but the elements inside can still be mutable if they’re mutable objects. For true deep immutability, you need to ensure the elements themselves are immutable too.
Thread-Safe Collections
When multiple threads access collections concurrently, you need thread-safe options. Groovy gives you access to all of Java’s concurrent collection classes, and adds a few convenience methods on top.
Thread-Safe Collections
import java.util.concurrent.*
// Synchronized wrappers
def syncList = Collections.synchronizedList([1, 2, 3])
def syncMap = Collections.synchronizedMap([a: 1, b: 2])
def syncSet = Collections.synchronizedSet([1, 2, 3] as Set)
// Using asSynchronized() - Groovy shortcut
def safeList = [1, 2, 3].asSynchronized()
def safeMap = [a: 1, b: 2].asSynchronized()
// Java concurrent collections (best performance)
def concurrentMap = new ConcurrentHashMap([users: 0, sessions: 0])
def copyOnWriteList = new CopyOnWriteArrayList(['reader1', 'reader2'])
def concurrentSet = ConcurrentHashMap.newKeySet()
concurrentSet.addAll(['tag1', 'tag2', 'tag3'])
println "ConcurrentHashMap: ${concurrentMap}"
println "CopyOnWriteArrayList: ${copyOnWriteList}"
println "Concurrent Set: ${concurrentSet}"
// Atomic updates
concurrentMap.compute('users') { key, val -> val + 1 }
concurrentMap.compute('sessions') { key, val -> val + 5 }
println "After updates: ${concurrentMap}"
Output
ConcurrentHashMap: {users:0, sessions:0}
CopyOnWriteArrayList: [reader1, reader2]
Concurrent Set: [tag1, tag2, tag3]
After updates: {users:1, sessions:5}
Rule of thumb: Use
asSynchronized()for simple cases. UseConcurrentHashMapfor high-concurrency maps. UseCopyOnWriteArrayListwhen reads vastly outnumber writes.
Performance Comparison
Different collection types have different performance characteristics. Here’s what matters for large datasets:
| Operation | ArrayList | LinkedList | HashSet | HashMap |
|---|---|---|---|---|
| Add at end | O(1) amortized | O(1) | O(1) | O(1) |
| Add at start | O(n) | O(1) | N/A | N/A |
| Get by index | O(1) | O(n) | N/A | O(1) by key |
| Contains | O(n) | O(n) | O(1) | O(1) by key |
| Remove | O(n) | O(1) if at iterator | O(1) | O(1) |
| Iterate all | O(n) | O(n) | O(n) | O(n) |
Performance Demonstration
def size = 100_000
// List vs Set for 'contains' check
def list = (1..size).toList()
def set = (1..size).toSet()
// Time a contains check on both
def target = size - 1
def listStart = System.nanoTime()
1000.times { list.contains(target) }
def listTime = (System.nanoTime() - listStart) / 1_000_000
def setStart = System.nanoTime()
1000.times { set.contains(target) }
def setTime = (System.nanoTime() - setStart) / 1_000_000
println "List contains (1000x): ${listTime}ms"
println "Set contains (1000x): ${setTime}ms"
println "Set is ~${Math.round(listTime / Math.max(setTime, 1))}x faster for contains"
// Map vs List for lookups
def mapLookup = (1..size).collectEntries { [(it): "value_${it}"] }
def mapStart = System.nanoTime()
1000.times { mapLookup[target] }
def mapTime = (System.nanoTime() - mapStart) / 1_000_000
println "Map lookup (1000x): ${mapTime}ms"
Output
List contains (1000x): 482ms Set contains (1000x): 1ms Set is ~482x faster for contains Map lookup (1000x): 1ms
The takeaway is clear: if you’re doing frequent contains() checks, use a Set. If you need key-based lookups, use a Map. Lists are only fast for index-based access and appending at the end.
Real-World Patterns
Pattern 1: Data Transformation Pipeline
CSV Processing Pipeline
// Simulate CSV rows
def csvRows = [
'Alice,Engineering,95000',
'Bob,Marketing,62000',
'Charlie,Engineering,81000',
'Diana,Marketing,73000',
'Eve,Engineering,110000'
]
def result = csvRows
.collect { it.split(',') }
.collect { [name: it[0], dept: it[1], salary: it[2] as BigDecimal] }
.findAll { it.salary > 70000 }
.groupBy { it.dept }
.collectEntries { dept, people ->
[dept, [
avg: people*.salary.sum() / people.size(),
names: people*.name
]]
}
result.each { dept, stats ->
println "${dept}: avg \$${stats.avg}, team: ${stats.names}"
}
Output
Engineering: avg $95333.3333333333333333333333333333, team: [Alice, Charlie, Eve] Marketing: avg $73000, team: [Diana]
Pattern 2: Configuration Merging
Map Merging Pattern
// Default config + environment overrides
def defaults = [timeout: 30, retries: 3, debug: false, logLevel: 'INFO']
def envConfig = [timeout: 60, debug: true, logLevel: 'DEBUG']
// Merge: environment overrides defaults
def config = defaults + envConfig
println "Final config: ${config}"
// Deep merge for nested maps
def deepMerge(Map base, Map override) {
def result = base.collectEntries { k, v -> [k, v] }
override.each { k, v ->
result[k] = (v instanceof Map && result[k] instanceof Map) ?
deepMerge(result[k], v) : v
}
return result
}
def baseConfig = [db: [host: 'localhost', port: 5432, pool: [min: 5, max: 20]]]
def prodConfig = [db: [host: 'prod-db.example.com', pool: [min: 10]]]
println "Deep merged: ${deepMerge(baseConfig, prodConfig)}"
Output
Final config: [timeout:60, retries:3, debug:true, logLevel:DEBUG] Deep merged: [db:[host:prod-db.example.com, port:5432, pool:[min:10, max:20]]]
Pattern 3: collectEntries – Build Maps from Lists
collectEntries Pattern
// Build a lookup map from a list of objects
def users = [
[id: 1, name: 'Alice', email: 'alice@example.com'],
[id: 2, name: 'Bob', email: 'bob@example.com'],
[id: 3, name: 'Charlie', email: 'charlie@example.com']
]
// id -> user lookup
def userById = users.collectEntries { [(it.id): it] }
println "User 2: ${userById[2].name}"
// name -> email lookup
def emailLookup = users.collectEntries { [(it.name): it.email] }
println "Alice's email: ${emailLookup.Alice}"
// Frequency map from a list
def words = 'the cat sat on the mat the cat'.split(' ')
def freq = words.toList().countBy { it }
println "Word frequency: ${freq}"
println "Most common: ${freq.max { it.value }.key}"
Output
User 2: Bob Alice's email: alice@example.com Word frequency: [the:3, cat:2, sat:1, on:1, mat:1] Most common: the
Common Pitfalls
Pitfall 1: sort() Modifies in Place by Default
sort() Pitfall
def original = [3, 1, 4, 1, 5]
// BAD: This modifies the original list!
def sorted = original.sort()
println "Original is now sorted: ${original}"
println "Same object: ${original.is(sorted)}"
// GOOD: Pass false to avoid mutation
def original2 = [3, 1, 4, 1, 5]
def sorted2 = original2.sort(false)
println "Original2 unchanged: ${original2}"
println "Sorted copy: ${sorted2}"
Output
Original is now sorted: [1, 1, 3, 4, 5] Same object: true Original2 unchanged: [3, 1, 4, 1, 5] Sorted copy: [1, 1, 3, 4, 5]
Pitfall 2: GString Keys in Maps
GString Key Pitfall
def key = 'name'
def map = [:]
// These create TWO different entries!
map["${key}"] = 'Alice' // GString key
map[key] = 'Bob' // String key
println "Map size: ${map.size()}" // 2, not 1!
println "Map: ${map}"
// Fix: always use .toString() or plain strings
map["${key}".toString()] = 'Charlie'
println "After fix: ${map[key]}"
Output
Map size: 2 Map: [name:Alice, name:Bob] After fix: Charlie
Pitfall 3: ConcurrentModificationException
Concurrent Modification Pitfall
// BAD: Modifying while iterating
def numbers = [1, 2, 3, 4, 5]
try {
numbers.each { if (it % 2 == 0) numbers.remove(it as Object) }
} catch (ConcurrentModificationException e) {
println "Error: ${e.class.simpleName}"
}
// GOOD: Use removeAll or findAll instead
def numbers2 = [1, 2, 3, 4, 5]
numbers2.removeAll { it % 2 == 0 }
println "Odds only: ${numbers2}"
// BETTER: Create a new filtered collection
def numbers3 = [1, 2, 3, 4, 5]
def odds = numbers3.findAll { it % 2 != 0 }
println "Odds (new list): ${odds}"
Output
Error: ConcurrentModificationException Odds only: [1, 3, 5] Odds (new list): [1, 3, 5]
Conclusion
We’ve covered the full range of Groovy collections in this post – from the four core types (lists, maps, sets, and ranges) to 12 essential methods, immutability, thread safety, performance trade-offs, and real-world patterns. Groovy’s collection framework is arguably the most productive part of the language, turning multi-line Java loops into single, readable expressions.
The key thing to take away is that these methods – each(), collect(), findAll(), inject(), groupBy(), and the rest – work across all collection types. Learn them once, apply them everywhere. That’s the power of the GDK.
For deeper coverage of each collection type, check out our dedicated posts linked below.
Summary
- Groovy has 4 core collection types: List (ordered, duplicates), Map (key-value), Set (unique), and Range (sequences)
- GDK methods like
collect(),findAll(),inject(), andgroupBy()work across all collection types - Use
sort(false)to avoid mutating the original list - Choose Sets over Lists when you need fast
contains()checks - Chain methods together for clean, functional-style data pipelines
If you also work with build tools, CI/CD pipelines, or cloud CLIs, check out Command Playground to practice 105+ CLI tools directly in your browser — no install needed.
Up next: Groovy For Loop – Examples and Usage Guide
Frequently Asked Questions
What is the difference between a Groovy List and a Set?
A List is ordered and allows duplicates – elements are stored by index. A Set does not allow duplicates and provides O(1) contains() checks. Use a List when order and position matter, use a Set when you need uniqueness or fast membership testing. By default, Groovy Lists are ArrayList and Sets are LinkedHashSet.
How do I make a Groovy collection immutable?
Use the asImmutable() or asUnmodifiable() method on any collection. For example: def frozen = [1, 2, 3].asImmutable(). Any attempt to add, remove, or modify elements will throw an UnsupportedOperationException. You can also use Collections.unmodifiableList(), Collections.unmodifiableMap(), etc.
What is the collect() method in Groovy?
collect() transforms every element of a collection by applying a closure and returns a new list with the results. It is equivalent to map() in Java Streams or other functional languages. Example: [1, 2, 3].collect { it * 2 } returns [2, 4, 6]. The original collection is not modified.
How does inject() work in Groovy collections?
inject(initialValue, closure) reduces a collection to a single value by applying a closure with an accumulator. The first parameter is the initial accumulator value. The closure receives the current accumulator and each element. Example: [1, 2, 3].inject(0) { acc, val -> acc + val } returns 6. It is also known as fold or reduce in other languages.
Which Groovy collection type should I use for thread-safe code?
For thread-safe operations, use ConcurrentHashMap for maps, CopyOnWriteArrayList for lists with mostly reads, or Collections.synchronizedList/Map/Set for simple wrappers. Groovy also provides shortcut methods like asSynchronized(). For best performance under high concurrency, prefer the java.util.concurrent classes over synchronized wrappers.
Related Posts
Previous in Series: Groovy Range – Examples and Usage Guide
Next in Series: Groovy For Loop – Examples and Usage Guide
Related Topics You Might Like:
- Groovy List Tutorial – Complete Guide with Examples
- Groovy Map Tutorial – The Complete Guide
- Groovy Set – Examples and Usage Guide
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment