Groovy Streams vs Closures – When to Use What (10+ Examples)

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.

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

FeatureJava StreamsGroovy Closures (GDK)
Filteringstream().filter()findAll { }
Transformationstream().map()collect { }
Reductionstream().reduce()inject { }
First matchstream().findFirst()find { }
GroupingCollectors.groupingBy()groupBy { }
Flat mappingstream().flatMap()collectMany { }
Sortingstream().sorted()sort { } / toSorted { }
EvaluationLazyEager
Parallel supportparallelStream()Not built-in (use GPars)
ReusabilitySingle 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 processingparallelStream() 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, or collectMany – 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.

Previous in Series: Groovy Functional Interfaces and SAM Types

Next in Series: Groovy Memoization – Cache Function Results

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 *