12 Essential Groovy Collections Methods Every Developer Must Know

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.

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 with 1..10
  • GDK methods – Methods like collect(), findAll(), groupBy(), and inject() 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:

FeatureListMapSetRange
OrderedYesYes (LinkedHashMap)No (HashSet) / Yes (LinkedHashSet)Yes
DuplicatesAllowedKeys uniqueNo duplicatesN/A
Indexed accessYes (by position)Yes (by key)NoYes (by position)
Literal syntax[][:][] as Seta..b
Default Java typeArrayListLinkedHashMapLinkedHashSetIntRange / ObjectRange
Best forOrdered data, queuesKey-value lookupsUnique values, membershipSequences, loops
Null valuesYesYes (keys and values)One null allowedNo

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() or new ArrayDeque()
  • Need a stack (LIFO)? Use new ArrayDeque() with push() and pop()

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. Use ConcurrentHashMap for high-concurrency maps. Use CopyOnWriteArrayList when reads vastly outnumber writes.

Performance Comparison

Different collection types have different performance characteristics. Here’s what matters for large datasets:

OperationArrayListLinkedListHashSetHashMap
Add at endO(1) amortizedO(1)O(1)O(1)
Add at startO(n)O(1)N/AN/A
Get by indexO(1)O(n)N/AO(1) by key
ContainsO(n)O(n)O(1)O(1) by key
RemoveO(n)O(1) if at iteratorO(1)O(1)
Iterate allO(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(), and groupBy() 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.

Previous in Series: Groovy Range – Examples and Usage Guide

Next in Series: Groovy For Loop – Examples and Usage Guide

Related Topics You Might Like:

This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *