Learn the differences between Groovy streams compared to Java closures. 10+ tested examples comparing collect, findAll, inject with stream(), filter(), map(), reduce().
“Groovy gives you two paths to functional programming – Java Streams and native closures. Knowing when to walk which path is what separates good code from great code.”
Venkat Subramaniam, Functional Programming in Java
Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate | Reading Time: 18 minutes
Should you use Java Streams or Groovy’s native closure methods like collect, findAll, and inject? That question trips up a lot of developers because groovy streams via the Java API and Groovy’s own GDK methods both let you filter, transform, and reduce data in a functional style – but they behave differently in ways that matter.
The confusion is understandable. Since Groovy runs on the JVM, you have full access to the Java Streams API introduced in Java 8. But Groovy had its own functional collection methods long before Java Streams existed. These closure-based methods – collect, findAll, inject, groupBy – are part of the GDK and feel much more natural in Groovy code.
In this post, we’ll put Groovy streams and closures side by side with 10+ tested examples. You’ll see the exact same problem solved both ways so you can judge which is more readable, more concise, and more appropriate for your project. If you’re new to closures, start with our Groovy Closures Complete Guide first. If you already know closures but want to explore higher-order functions, check out our higher-order functions post on collect, inject, and groupBy.
After working through these examples, you’ll have a clear mental model for when to reach for .stream() and when to stick with Groovy’s native approach.
Table of Contents
Streams vs Closures – The Core Difference
Before we jump into examples, let’s get clear on what each approach actually is.
Java Streams (available via java.util.stream.Stream) are a pipeline-based API. You create a stream from a collection, chain intermediate operations like filter() and map(), and finish with a terminal operation like collect() or reduce(). Streams are lazy – nothing happens until the terminal operation is called. They also support parallel processing via .parallelStream().
Groovy Closures are the GDK’s collection methods – collect, findAll, find, inject, each, groupBy, and many more. These are eager – each method executes immediately and returns a result. They’re added directly to collection classes by the Groovy Development Kit, so they feel like native methods on List, Map, and Set.
According to the official Groovy GDK documentation, these closure-based methods are the idiomatic way to work with collections in Groovy. The Streams API is available because Groovy sits on the JVM, but Groovy’s own methods are typically shorter and more expressive.
Quick Comparison Table
| Feature | Java Streams | Groovy Closures (GDK) |
|---|---|---|
| Filtering | stream().filter() | findAll { } |
| Transformation | stream().map() | collect { } |
| Reduction | stream().reduce() | inject { } |
| First match | stream().findFirst() | find { } |
| Grouping | Collectors.groupingBy() | groupBy { } |
| Flat mapping | stream().flatMap() | collectMany { } |
| Sorting | stream().sorted() | sort { } / toSorted { } |
| Evaluation | Lazy | Eager |
| Parallel support | parallelStream() | Not built-in (use GPars) |
| Reusability | Single use (one traversal) | Collections are reusable |
Here is all of this in action with real, runnable code.
10 Practical Examples
Example 1: Filtering a List – findAll vs filter
What we’re doing: Filtering a list of numbers to keep only even values – the most basic functional operation compared both ways.
Example 1: Filtering – Closure vs Stream
def numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// Groovy closure way - findAll
def evenClosure = numbers.findAll { it % 2 == 0 }
println "Closure findAll: ${evenClosure}"
// Java Stream way - filter + collect
def evenStream = numbers.stream()
.filter { it % 2 == 0 }
.collect(java.util.stream.Collectors.toList())
println "Stream filter: ${evenStream}"
// Groovy closure - multiple conditions
def filtered = numbers.findAll { it % 2 == 0 && it > 4 }
println "Closure multi: ${filtered}"
// Stream - multiple conditions
def filteredStream = numbers.stream()
.filter { it % 2 == 0 }
.filter { it > 4 }
.collect(java.util.stream.Collectors.toList())
println "Stream multi: ${filteredStream}"
Output
Closure findAll: [2, 4, 6, 8, 10] Stream filter: [2, 4, 6, 8, 10] Closure multi: [6, 8, 10] Stream multi: [6, 8, 10]
What happened here: Both approaches produce the same result, but the Groovy closure version is noticeably shorter. With findAll, you just pass a closure and get a filtered list back – no .stream() call, no Collectors.toList() at the end. The Stream approach gives you the option to chain multiple .filter() calls, but you can combine conditions inside a single findAll closure just as easily.
Example 2: Transforming Data – collect vs map
What we’re doing: Transforming a list of strings to uppercase – comparing Groovy’s collect with Stream’s map.
Example 2: Transformation – collect vs map
def names = ['alice', 'bob', 'charlie', 'diana']
// Groovy closure - collect
def upperClosure = names.collect { it.toUpperCase() }
println "Closure collect: ${upperClosure}"
// Java Stream - map + collect
def upperStream = names.stream()
.map { it.toUpperCase() }
.collect(java.util.stream.Collectors.toList())
println "Stream map: ${upperStream}"
// Transform with index - Groovy makes it easy
def withIndex = names.collect { name -> "${name.capitalize()}" }
println "Capitalized: ${withIndex}"
// Groovy withIndex - no Stream equivalent this clean
def indexed = names.withIndex().collect { name, idx ->
"${idx + 1}. ${name.capitalize()}"
}
println "With index: ${indexed}"
// Stream with index - requires AtomicInteger or IntStream workaround
import java.util.concurrent.atomic.AtomicInteger
def counter = new AtomicInteger(0)
def indexedStream = names.stream()
.map { "${counter.incrementAndGet()}. ${it.capitalize()}" }
.collect(java.util.stream.Collectors.toList())
println "Stream indexed: ${indexedStream}"
Output
Closure collect: [ALICE, BOB, CHARLIE, DIANA] Stream map: [ALICE, BOB, CHARLIE, DIANA] Capitalized: [Alice, Bob, Charlie, Diana] With index: [1. Alice, 2. Bob, 3. Charlie, 4. Diana] Stream indexed: [1. Alice, 2. Bob, 3. Charlie, 4. Diana]
What happened here: For simple transformations, both approaches work equally well. But once you need the index, Groovy’s withIndex() method wins hands down. Java Streams don’t have a built-in way to access the index during map(), so you have to use workarounds like AtomicInteger or IntStream.range(). This is an area where Groovy’s GDK really shines.
Example 3: Reducing to a Single Value – inject vs reduce
What we’re doing: Reducing a list to a single value – sum, product, and string concatenation – comparing both approaches.
Example 3: Reduction – inject vs reduce
def numbers = [1, 2, 3, 4, 5]
// Groovy closure - inject (fold)
def sumClosure = numbers.inject(0) { acc, val -> acc + val }
println "Closure inject sum: ${sumClosure}"
// Java Stream - reduce
def sumStream = numbers.stream()
.reduce(0) { acc, val -> acc + val }
println "Stream reduce sum: ${sumStream}"
// Groovy - product with inject
def product = numbers.inject(1) { acc, val -> acc * val }
println "Closure product: ${product}"
// Stream - product with reduce
def productStream = numbers.stream()
.reduce(1) { acc, val -> acc * val }
println "Stream product: ${productStream}"
// Groovy - sum() shortcut (no inject needed!)
println "Groovy sum(): ${numbers.sum()}"
// Groovy - inject for string building
def words = ['Groovy', 'is', 'awesome']
def sentence = words.inject('') { acc, word ->
acc ? "${acc} ${word}" : word
}
println "Closure sentence: ${sentence}"
// Stream - joining collector
def sentenceStream = words.stream()
.collect(java.util.stream.Collectors.joining(' '))
println "Stream joining: ${sentenceStream}"
Output
Closure inject sum: 15 Stream reduce sum: 15 Closure product: 120 Stream product: 120 Groovy sum(): 15 Closure sentence: Groovy is awesome Stream joining: Groovy is awesome
What happened here: For basic reduction, both inject and reduce are almost identical. But Groovy provides convenience methods like sum() that make common operations trivially simple. For joining strings, Streams have the dedicated Collectors.joining() which is actually quite clean. One interesting difference: Groovy’s inject always requires an initial value, while Stream’s reduce has both a version with and without an initial value (the no-argument version returns an Optional).
Example 4: Finding Elements – find vs findFirst
What we’re doing: Finding the first element that matches a condition – Groovy’s find vs Stream’s filter + findFirst.
Example 4: Finding Elements
def people = [
[name: 'Alice', age: 30],
[name: 'Bob', age: 25],
[name: 'Charlie', age: 35],
[name: 'Diana', age: 28],
[name: 'Eve', age: 35]
]
// Groovy - find (returns first match or null)
def firstOver30 = people.find { it.age > 30 }
println "Closure find: ${firstOver30}"
// Stream - filter + findFirst (returns Optional)
def firstOver30Stream = people.stream()
.filter { it.age > 30 }
.findFirst()
println "Stream findFirst: ${firstOver30Stream.get()}"
// Groovy - find returns null when nothing matches
def over50 = people.find { it.age > 50 }
println "No match closure: ${over50}"
// Stream - findFirst returns empty Optional
def over50Stream = people.stream()
.filter { it.age > 50 }
.findFirst()
println "No match stream: ${over50Stream.isPresent()}"
println "Stream orElse: ${over50Stream.orElse([name: 'Nobody', age: 0])}"
// Groovy - every and any (check conditions)
println "All over 20? ${people.every { it.age > 20 }}"
println "Any over 34? ${people.any { it.age > 34 }}"
// Stream equivalents
println "Stream allMatch: ${people.stream().allMatch { it.age > 20 }}"
println "Stream anyMatch: ${people.stream().anyMatch { it.age > 34 }}"
Output
Closure find: [name:Charlie, age:35] Stream findFirst: [name:Charlie, age:35] No match closure: null No match stream: false Stream orElse: [name:Nobody, age:0] All over 20? true Any over 34? true Stream allMatch: true Stream anyMatch: true
What happened here: Groovy’s find is clean and direct – it returns the first match or null. The Stream approach requires filter followed by findFirst, and gives you an Optional which you need to unwrap. The Optional pattern is useful in Java where null safety isn’t built-in, but in Groovy where you have the safe navigation operator (?.), returning null is perfectly fine. Similarly, Groovy’s every and any map directly to allMatch and anyMatch.
Example 5: Grouping and Partitioning Data
What we’re doing: Grouping data by a key – Groovy’s groupBy vs Stream’s Collectors.groupingBy.
Example 5: Grouping Data
def employees = [
[name: 'Alice', dept: 'Engineering', salary: 95000],
[name: 'Bob', dept: 'Marketing', salary: 75000],
[name: 'Charlie', dept: 'Engineering', salary: 105000],
[name: 'Diana', dept: 'Marketing', salary: 80000],
[name: 'Eve', dept: 'Engineering', salary: 92000],
[name: 'Frank', dept: 'Sales', salary: 70000]
]
// Groovy - groupBy
def byDeptClosure = employees.groupBy { it.dept }
println "Closure groupBy:"
byDeptClosure.each { dept, emps ->
println " ${dept}: ${emps.collect { it.name }}"
}
// Stream - Collectors.groupingBy
import java.util.stream.Collectors
def byDeptStream = employees.stream()
.collect(Collectors.groupingBy { it.dept })
println "\nStream groupingBy:"
byDeptStream.each { dept, emps ->
println " ${dept}: ${emps.collect { it.name }}"
}
// Groovy - countBy (no stream equivalent this simple)
def countByDept = employees.countBy { it.dept }
println "\nClosure countBy: ${countByDept}"
// Stream - counting
def countStream = employees.stream()
.collect(Collectors.groupingBy({ it.dept }, Collectors.counting()))
println "Stream counting: ${countStream}"
// Groovy - partition into two groups
def highEarners = employees.split { it.salary >= 90000 }
println "\nHigh earners: ${highEarners[0].collect { it.name }}"
println "Others: ${highEarners[1].collect { it.name }}"
Output
Closure groupBy: Engineering: [Alice, Charlie, Eve] Marketing: [Bob, Diana] Sales: [Frank] Stream groupingBy: Engineering: [Alice, Charlie, Eve] Sales: [Frank] Marketing: [Bob, Diana] Closure countBy: [Engineering:3, Marketing:2, Sales:1] Stream counting: [Engineering:3, Sales:1, Marketing:2] High earners: [Alice, Charlie, Eve] Others: [Bob, Diana, Frank]
What happened here: Grouping is where Groovy’s GDK really outshines Streams. The groupBy method is one line. The Stream equivalent needs Collectors.groupingBy. Groovy also has countBy (which counts elements per group) and split (which partitions into exactly two groups). The Stream API requires chaining Collectors.counting() as a downstream collector, which is more verbose. For more on groupBy and friends, see our higher-order functions guide.
Example 6: Flat Mapping – collectMany vs flatMap
What we’re doing: Flattening nested lists – Groovy’s collectMany vs Stream’s flatMap.
Example 6: Flat Mapping
def departments = [
[name: 'Engineering', skills: ['Java', 'Groovy', 'Python']],
[name: 'Marketing', skills: ['SEO', 'Analytics']],
[name: 'Design', skills: ['Figma', 'CSS', 'Groovy']]
]
// Groovy - collectMany
def allSkillsClosure = departments.collectMany { it.skills }
println "Closure collectMany: ${allSkillsClosure}"
// Stream - flatMap
import java.util.stream.Collectors
def allSkillsStream = departments.stream()
.flatMap { it.skills.stream() }
.collect(Collectors.toList())
println "Stream flatMap: ${allSkillsStream}"
// Groovy - collectMany + unique
def uniqueSkills = departments.collectMany { it.skills }.unique()
println "Unique (closure): ${uniqueSkills}"
// Stream - flatMap + distinct
def uniqueStream = departments.stream()
.flatMap { it.skills.stream() }
.distinct()
.collect(Collectors.toList())
println "Unique (stream): ${uniqueStream}"
// Groovy - nested flatten
def nested = [[1, 2, [3, 4]], [5, [6, 7]], [8]]
println "Flatten nested: ${nested.flatten()}"
// Stream can't do deep flatten - only one level
def oneLevel = nested.stream()
.flatMap { it instanceof List ? it.stream() : java.util.stream.Stream.of(it) }
.collect(Collectors.toList())
println "Stream one level: ${oneLevel}"
Output
Closure collectMany: [Java, Groovy, Python, SEO, Analytics, Figma, CSS, Groovy] Stream flatMap: [Java, Groovy, Python, SEO, Analytics, Figma, CSS, Groovy] Unique (closure): [Java, Groovy, Python, SEO, Analytics, Figma, CSS] Unique (stream): [Java, Groovy, Python, SEO, Analytics, Figma, CSS] Flatten nested: [1, 2, 3, 4, 5, 6, 7, 8] Stream one level: [1, 2, [3, 4], 5, [6, 7], 8]
What happened here: For one-level flattening, both approaches work similarly, but notice that flatMap requires calling .stream() on the inner list – an extra step that collectMany doesn’t need. The real difference shows with deep nesting: Groovy’s flatten() handles arbitrary nesting depth recursively, while Streams only flatten one level at a time. If you have deeply nested structures, Groovy’s approach is far simpler.
Example 7: Chaining Multiple Operations
What we’re doing: Chaining filter, transform, and reduce – seeing how multi-step pipelines compare.
Example 7: Chaining Operations
def products = [
[name: 'Laptop', price: 999.99, category: 'Electronics'],
[name: 'Book', price: 29.99, category: 'Education'],
[name: 'Phone', price: 699.99, category: 'Electronics'],
[name: 'Course', price: 49.99, category: 'Education'],
[name: 'Tablet', price: 449.99, category: 'Electronics'],
[name: 'Pen', price: 2.99, category: 'Office']
]
// Groovy - chain: filter electronics, get names, sort them
def electronicNames = products
.findAll { it.category == 'Electronics' }
.collect { it.name }
.sort()
println "Closure chain: ${electronicNames}"
// Stream - same chain
import java.util.stream.Collectors
def electronicStream = products.stream()
.filter { it.category == 'Electronics' }
.map { it.name }
.sorted()
.collect(Collectors.toList())
println "Stream chain: ${electronicStream}"
// Groovy - total price of electronics
def totalElectronics = products
.findAll { it.category == 'Electronics' }
.sum { it.price }
println "Closure total: ${totalElectronics}"
// Stream - total price
def totalStream = products.stream()
.filter { it.category == 'Electronics' }
.mapToDouble { (double) it.price }
.sum()
println "Stream total: ${totalStream}"
// Groovy - transform and group in one pipeline
def priceRanges = products.collect {
def range = it.price > 500 ? 'Premium' : it.price > 20 ? 'Mid' : 'Budget'
[name: it.name, range: range]
}.groupBy { it.range }
println "\nPrice ranges:"
priceRanges.each { range, items ->
println " ${range}: ${items.collect { it.name }}"
}
Output
Closure chain: [Laptop, Phone, Tablet] Stream chain: [Laptop, Phone, Tablet] Closure total: 2149.97 Stream total: 2149.9700000000003 Price ranges: Premium: [Laptop, Phone] Mid: [Book, Course, Tablet] Budget: [Pen]
What happened here: Multi-step pipelines are where both approaches feel similar in structure. You chain operations one after another. The Groovy version is slightly shorter because you don’t need .stream() at the start or Collectors.toList() at the end. For summing, Groovy’s sum { } is more concise than Stream’s mapToDouble followed by sum(). The last example shows how Groovy lets you chain collect into groupBy smoothly – this would be much more verbose with Streams.
Example 8: Lazy Evaluation with Streams
What we’re doing: Demonstrating Stream’s lazy evaluation – the one area where Streams have a genuine technical advantage.
Example 8: Lazy Evaluation
def numbers = (1..20).toList()
// Groovy closure - EAGER: processes ALL elements, then takes first 3
println "=== Closure (Eager) ==="
def eagerResult = numbers
.findAll {
print "filter(${it}) "
it % 2 == 0
}
.collect {
print "map(${it}) "
it * 10
}
.take(3)
println "\nResult: ${eagerResult}"
println ""
// Stream - LAZY: stops processing after finding 3 matches
println "=== Stream (Lazy) ==="
import java.util.stream.Collectors
def lazyResult = numbers.stream()
.filter {
print "filter(${it}) "
it % 2 == 0
}
.map {
print "map(${it}) "
it * 10
}
.limit(3)
.collect(Collectors.toList())
println "\nResult: ${lazyResult}"
println ""
// Stream - infinite sequences (not possible with eager closures)
println "=== Infinite Stream ==="
import java.util.stream.Stream as JStream
def firstFiveSquares = JStream.iterate(1) { it + 1 }
.map { it * it }
.limit(5)
.collect(Collectors.toList())
println "First 5 squares: ${firstFiveSquares}"
Output
=== Closure (Eager) === filter(1) filter(2) filter(3) filter(4) filter(5) filter(6) filter(7) filter(8) filter(9) filter(10) filter(11) filter(12) filter(13) filter(14) filter(15) filter(16) filter(17) filter(18) filter(19) filter(20) map(2) map(4) map(6) map(8) map(10) map(12) map(14) map(16) map(18) map(20) Result: [20, 40, 60] === Stream (Lazy) === filter(1) filter(2) map(2) filter(3) filter(4) map(4) filter(5) filter(6) map(6) Result: [20, 40, 60] === Infinite Stream === First 5 squares: [1, 4, 9, 16, 25]
What happened here: This is the most important difference between the two approaches. Groovy’s closure methods are eager – findAll processed all 20 elements and created a full filtered list before collect even started. Then collect transformed all 10 even numbers, and finally take(3) kept just the first three. The Stream version was lazy – it stopped as soon as it found 3 matching elements, processing only 6 numbers instead of 20. For large datasets where you only need a subset, Streams are genuinely more efficient. Streams can also represent infinite sequences, something that eager collections simply can’t do.
Example 9: Parallel Processing with parallelStream
What we’re doing: Demonstrating parallel processing – a Streams-exclusive feature for CPU-intensive work.
Example 9: Parallel Processing
import java.util.stream.Collectors
// Simulate CPU-intensive work
def heavyComputation = { num ->
Thread.sleep(100) // Simulate 100ms work per item
num * num
}
def numbers = (1..10).toList()
// Sequential - Groovy closure
def start1 = System.currentTimeMillis()
def seqClosure = numbers.collect { heavyComputation(it) }
def time1 = System.currentTimeMillis() - start1
println "Sequential closure: ${seqClosure}"
println "Time: ${time1}ms"
// Sequential stream
def start2 = System.currentTimeMillis()
def seqStream = numbers.stream()
.map { heavyComputation(it) }
.collect(Collectors.toList())
def time2 = System.currentTimeMillis() - start2
println "\nSequential stream: ${seqStream}"
println "Time: ${time2}ms"
// Parallel stream - uses ForkJoinPool
def start3 = System.currentTimeMillis()
def parStream = numbers.parallelStream()
.map { heavyComputation(it) }
.collect(Collectors.toList())
def time3 = System.currentTimeMillis() - start3
println "\nParallel stream: ${parStream}"
println "Time: ${time3}ms"
println "\nSpeedup: ~${(time1 / time3).round(1)}x faster with parallelStream"
Output
Sequential closure: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] Time: 1015ms Sequential stream: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] Time: 1012ms Parallel stream: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] Time: 210ms Speedup: ~4.8x faster with parallelStream
What happened here: Parallel streams distribute work across multiple CPU cores using the ForkJoinPool. For CPU-intensive operations on large collections, this can provide significant speedup with zero extra code – just replace .stream() with .parallelStream(). Groovy’s native closure methods don’t have built-in parallel support (you’d need the GPars library for that). Important caveat: parallelStream has overhead, so it’s only worth using for genuinely CPU-heavy work on large datasets. For simple operations on small collections, it can actually be slower due to thread management overhead.
Example 10: Real-World Data Pipeline – Complete Comparison
What we’re doing: Building a complete data processing pipeline both ways – a realistic scenario of processing order data.
Example 10: Complete Data Pipeline
def orders = [
[id: 1, customer: 'Alice', items: ['Laptop', 'Mouse'], total: 1049.98, status: 'completed'],
[id: 2, customer: 'Bob', items: ['Book', 'Pen'], total: 32.98, status: 'completed'],
[id: 3, customer: 'Alice', items: ['Phone'], total: 699.99, status: 'pending'],
[id: 4, customer: 'Charlie', items: ['Tablet', 'Case', 'Screen Protector'], total: 509.97, status: 'completed'],
[id: 5, customer: 'Bob', items: ['Course'], total: 49.99, status: 'cancelled'],
[id: 6, customer: 'Diana', items: ['Laptop'], total: 999.99, status: 'completed'],
[id: 7, customer: 'Alice', items: ['Keyboard', 'Monitor'], total: 649.98, status: 'completed']
]
// ==============================
// GROOVY CLOSURE APPROACH
// ==============================
println "=== Groovy Closure Approach ==="
// 1. Filter completed orders
def completedOrders = orders.findAll { it.status == 'completed' }
// 2. Total revenue from completed orders
def totalRevenue = completedOrders.sum { it.total }
println "Total revenue: \$${totalRevenue}"
// 3. Average order value
def avgOrder = totalRevenue / completedOrders.size()
println "Avg order value: \$${avgOrder.round(2)}"
// 4. Revenue per customer
def revenueByCustomer = completedOrders.groupBy { it.customer }
.collectEntries { customer, custOrders ->
[customer, custOrders.sum { it.total }]
}
.sort { -it.value }
println "Revenue by customer: ${revenueByCustomer}"
// 5. Most popular items
def allItems = completedOrders.collectMany { it.items }
def itemCounts = allItems.countBy { it }.sort { -it.value }
println "Item popularity: ${itemCounts}"
// 6. Top customer
def topCustomer = revenueByCustomer.max { it.value }
println "Top customer: ${topCustomer.key} (\$${topCustomer.value})"
println ""
// ==============================
// JAVA STREAM APPROACH
// ==============================
println "=== Java Stream Approach ==="
import java.util.stream.Collectors
// 1 & 2. Filter and sum
def totalRevenueStream = orders.stream()
.filter { it.status == 'completed' }
.mapToDouble { (double) it.total }
.sum()
println "Total revenue: \$${totalRevenueStream}"
// 3. Average
def avgStream = orders.stream()
.filter { it.status == 'completed' }
.mapToDouble { (double) it.total }
.average()
.orElse(0.0)
println "Avg order value: \$${avgStream.round(2)}"
// 4. Revenue per customer (more verbose with streams)
def revByCustomerStream = orders.stream()
.filter { it.status == 'completed' }
.collect(Collectors.groupingBy(
{ it.customer },
{ new TreeMap<>() } as java.util.function.Supplier,
Collectors.summingDouble { (double) it.total }
))
println "Revenue by customer: ${revByCustomerStream}"
// 5. Item popularity
def itemCountsStream = orders.stream()
.filter { it.status == 'completed' }
.flatMap { it.items.stream() }
.collect(Collectors.groupingBy(
{ it },
{ new TreeMap<>() } as java.util.function.Supplier,
Collectors.counting()
))
println "Item popularity: ${itemCountsStream}"
Output
=== Groovy Closure Approach === Total revenue: $3242.90 Avg order value: $648.58 Revenue by customer: [Alice:1699.96, Diana:999.99, Charlie:509.97, Bob:32.98] Item popularity: [Laptop:2, Mouse:1, Book:1, Pen:1, Tablet:1, Case:1, Screen Protector:1, Keyboard:1, Monitor:1] Top customer: Alice ($1699.96) === Java Stream Approach === Total revenue: $3242.9 Avg order value: $648.58 Revenue by customer: [Alice:1699.96, Bob:32.98, Charlie:509.97, Diana:999.99] Item popularity: [Book:1, Case:1, Keyboard:1, Laptop:2, Monitor:1, Mouse:1, Pen:1, Screen Protector:1, Tablet:1]
What happened here: This real-world comparison makes the difference crystal clear. The Groovy closure approach reads like plain English – findAll, sum, groupBy, collectEntries, collectMany, countBy. The Stream approach requires more ceremony: Collectors.groupingBy, Collectors.summingDouble, Collectors.counting(). For data analysis tasks in Groovy, the closure approach is almost always more readable and concise.
Example 11 (Bonus): Collecting to Maps and Custom Collectors
What we’re doing: Comparing how both approaches build Maps from lists – a very common real-world requirement.
Example 11: Building Maps
def users = [
[id: 1, name: 'Alice', active: true],
[id: 2, name: 'Bob', active: false],
[id: 3, name: 'Charlie', active: true],
[id: 4, name: 'Diana', active: true]
]
// Groovy - collectEntries (list to map)
def idToName = users.collectEntries { [it.id, it.name] }
println "Closure map: ${idToName}"
// Stream - Collectors.toMap
import java.util.stream.Collectors
def idToNameStream = users.stream()
.collect(Collectors.toMap({ it.id }, { it.name }))
println "Stream map: ${idToNameStream}"
// Groovy - filtered collectEntries
def activeUsers = users.findAll { it.active }
.collectEntries { [it.id, it.name] }
println "Active (closure): ${activeUsers}"
// Stream - filter then toMap
def activeStream = users.stream()
.filter { it.active }
.collect(Collectors.toMap({ it.id }, { it.name }))
println "Active (stream): ${activeStream}"
// Groovy - unique to Groovy: collectEntries with transformation
def nameToStatus = users.collectEntries {
[it.name, it.active ? 'Active' : 'Inactive']
}
println "Status map: ${nameToStatus}"
Output
Closure map: [1:Alice, 2:Bob, 3:Charlie, 4:Diana] Stream map: [1:Alice, 2:Bob, 3:Charlie, 4:Diana] Active (closure): [1:Alice, 3:Charlie, 4:Diana] Active (stream): [1:Alice, 3:Charlie, 4:Diana] Status map: [Alice:Active, Bob:Inactive, Charlie:Active, Diana:Active]
What happened here: Groovy’s collectEntries is wonderfully expressive – you return a two-element list [key, value] and it builds the Map. The Stream approach uses Collectors.toMap with separate lambdas for key and value extraction, which is functional but more verbose. For simple list-to-map transformations, Groovy’s approach wins on readability every time.
Performance Considerations
Let’s be honest about performance – it matters, but maybe not the way you think.
For small to medium collections (under 10,000 elements): There is virtually no measurable difference between Groovy closures and Java Streams. The overhead of creating a stream pipeline vs calling GDK methods is negligible. Choose whichever reads better.
For large collections (10,000+ elements): Streams have two advantages:
- Lazy evaluation – if you only need the first few results from a large dataset, streams avoid processing the entire collection
- Parallel processing –
parallelStream()distributes work across CPU cores with zero extra code
Where Groovy closures actually win:
- No stream creation overhead
- No collector assembly overhead
- Direct mutation methods like
sort()that modify in place (no new collection created) - Methods like
sum(),max(),min()that are optimized in the GDK
The bottom line: don’t choose Streams for “performance” on a list of 50 elements. Choose based on readability, and reach for Streams when you genuinely need laziness or parallelism.
When to Use Streams vs Closures
Use Groovy Closures When:
- You’re writing idiomatic Groovy code and want maximum readability
- You need
groupBy,countBy,collectEntries, orcollectMany– they have no clean Stream equivalent - You need index access during iteration (
eachWithIndex,withIndex) - Your collection is small to medium sized
- You want to modify the collection in place
- Your team primarily writes Groovy
Use Java Streams When:
- You need lazy evaluation – processing large datasets where you only need a subset
- You need parallel processing for CPU-intensive operations
- You’re working with infinite or very large sequences
- You’re interoperating with Java libraries that return or expect Streams
- Your team has a strong Java background and prefers the Stream vocabulary
- You want specialized collectors like
Collectors.joining()or statistical collectors
Common Pitfalls
Pitfall 1: Reusing a Consumed Stream
Pitfall 1: Stream Reuse
def numbers = [1, 2, 3, 4, 5]
// Groovy closures - reuse the same collection many times, no problem
println "Sum: ${numbers.sum()}"
println "Max: ${numbers.max()}"
println "Even: ${numbers.findAll { it % 2 == 0 }}"
// Streams - can only be consumed ONCE
def stream = numbers.stream()
def sum = stream.reduce(0) { a, b -> a + b }
println "\nStream sum: ${sum}"
try {
// This WILL throw IllegalStateException!
stream.max { a, b -> a <=> b }
} catch (IllegalStateException e) {
println "ERROR: ${e.message}"
}
println "Fix: Create a new stream each time"
Output
Sum: 15 Max: 5 Even: [2, 4] Stream sum: 15 ERROR: stream has already been operated upon or closed Fix: Create a new stream each time
Pitfall 2: Confusing Groovy’s collect with Stream’s collect
Pitfall 2: collect Confusion
def names = ['alice', 'bob', 'charlie']
// Groovy's collect - transforms elements (like map)
def upper1 = names.collect { it.toUpperCase() }
println "Groovy collect: ${upper1}" // [ALICE, BOB, CHARLIE]
// Stream's collect - terminal operation that gathers results
import java.util.stream.Collectors
def upper2 = names.stream()
.map { it.toUpperCase() }
.collect(Collectors.toList()) // This is a DIFFERENT collect!
println "Stream collect: ${upper2}" // [ALICE, BOB, CHARLIE]
// WATCH OUT: If you call .collect {} on a stream, you get Groovy's collect
// acting on the Stream object itself, NOT the stream elements!
// Always use Collectors with streams.
Output
Groovy collect: [ALICE, BOB, CHARLIE] Stream collect: [ALICE, BOB, CHARLIE]
Pitfall 3: Assuming Streams Are Always Faster
Pitfall 3: Performance Assumptions
// For small collections, streams can actually be SLOWER
// due to stream pipeline setup overhead
def small = (1..100).toList()
// Benchmark: Groovy closure
def start1 = System.nanoTime()
10000.times { small.findAll { it % 2 == 0 }.collect { it * 2 } }
def time1 = (System.nanoTime() - start1) / 1_000_000
// Benchmark: Java Stream
import java.util.stream.Collectors
def start2 = System.nanoTime()
10000.times {
small.stream().filter { it % 2 == 0 }.map { it * 2 }
.collect(Collectors.toList())
}
def time2 = (System.nanoTime() - start2) / 1_000_000
println "Closure: ${time1}ms for 10,000 iterations"
println "Stream: ${time2}ms for 10,000 iterations"
println "Winner: ${time1 < time2 ? 'Closure' : 'Stream'}"
Output
Closure: 287ms for 10,000 iterations Stream: 312ms for 10,000 iterations Winner: Closure
Don’t be surprised if closures are slightly faster for small collections. The Stream API has setup costs for creating the pipeline, spliterator, and collector. These costs are amortized over large collections but are noticeable on small ones.
Conclusion
We’ve compared Groovy streams (Java Streams API) with Groovy’s native closure methods across 11 real-world examples. The verdict? For most Groovy code, native closures are the better choice. They’re shorter, more readable, and deeply integrated into the language. Methods like groupBy, collectEntries, countBy, and collectMany have no clean Stream equivalent.
But Streams aren’t without merit. When you need lazy evaluation, parallel processing, or you’re working with infinite sequences, Streams are the right tool. The key is knowing which tool fits which job.
Summary
- Groovy closures (
findAll,collect,inject) are the idiomatic choice for most Groovy code - Java Streams provide lazy evaluation – important when processing large datasets with early termination
parallelStream()enables easy multi-threaded processing, but only for CPU-intensive work- Streams are single-use; Groovy collections can be iterated multiple times
- For grouping, counting, and map-building, Groovy’s GDK methods are significantly more concise
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 Memoization – Cache Function Results
Frequently Asked Questions
Can I use Java Streams in Groovy?
Yes, absolutely. Since Groovy runs on the JVM, you have full access to the Java Streams API. You can call .stream() or .parallelStream() on any collection and use all Stream operations like filter(), map(), reduce(), and collect(Collectors.toList()). Groovy closures work directly as lambda replacements in Stream operations.
Are Groovy closures the same as Java lambdas?
Not exactly. Groovy closures are instances of the Closure class and have additional features like delegation, currying, and memoization. However, Groovy automatically converts closures to functional interfaces (SAM types) when needed, so you can pass a Groovy closure anywhere a Java lambda or functional interface is expected – including in Stream operations.
Should I use Streams or closures for better performance?
For small to medium collections (under 10,000 elements), there is no meaningful performance difference. Groovy closures can actually be slightly faster due to lower overhead. For large datasets where you only need a subset of results, Streams’ lazy evaluation avoids processing unnecessary elements. For CPU-intensive work on large collections, parallelStream() can provide significant speedup.
What is the Groovy equivalent of Stream.flatMap()?
Groovy’s equivalent is collectMany {}. For example, instead of list.stream().flatMap { it.subList.stream() }.collect(Collectors.toList()), you write list.collectMany { it.subList }. Groovy also has flatten() for deeply nested lists, which has no direct Stream equivalent.
Why can’t I reuse a Java Stream in Groovy?
Java Streams are designed for single use – once a terminal operation (like collect, reduce, or forEach) is called, the stream is consumed and cannot be traversed again. Attempting to reuse it throws IllegalStateException. This is a fundamental design choice in the Streams API. Groovy collections, by contrast, can be iterated as many times as you want, which is one advantage of using Groovy’s native closure methods.
Related Posts
Previous in Series: Groovy Functional Interfaces and SAM Types
Next in Series: Groovy Memoization – Cache Function Results
Related Topics You Might Like:
- Groovy Closures – The Complete Guide
- Groovy Higher-Order Functions – collect, inject, groupBy
- Groovy Memoization – Cache Function Results
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment