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

Your Java teammates write stream().filter().map() while your Groovy code says findAll and collect, and sooner or later a code review asks which style belongs in the codebase. Groovy streams work exactly as they do in Java, and the native closure methods often read better. This guide compares both with 10 tested examples on Groovy 5.0.6 so you know when each wins.

“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.”

Last Updated: July 2026 | Tested on: Groovy 5.0.6, 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.

The diagram below shows the single biggest difference between the two: how one item travels through each pipeline. Eager closures finish every stage on the whole list before moving on, while a lazy stream pulls one item all the way through and can stop early once the limit is reached:

Java Stream (Lazy)Groovy Closure (Eager)noyes, limit hitSource list of 20 numbersfindAll runs on ALL 20 itemsbuilds full filtered listPull one itemthrough filter then mapcollect runs on ALL matchesbuilds full mapped listtake(3) keeps first 3from finished listGot 3 results yet?Stop earlyrest never touchedSame 3 results, less workwhen lazyGroovy Streams vs Closures: Eager vs Lazy Evaluation Flow

Groovy 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: 1108ms

Sequential stream:  [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Time: 1116ms

Parallel stream:    [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Time: 217ms

Speedup: ~5.1x 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. One more thing: your exact timings will differ from run to run and machine to machine, so focus on the ratio rather than the raw milliseconds.

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 how groovy streams and closure methods compare on 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 groovy streams pipelines. 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, 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: 301.9069ms for 10,000 iterations
Stream:  303.0101ms for 10,000 iterations
Winner:  Closure

Don’t be surprised if closures are slightly faster for small collections, and don’t read too much into any single run either. This is a naive micro benchmark, so the numbers swing between runs and the winner can even flip depending on JIT warmup and system load. The real takeaway is that streams are not automatically faster. 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. Groovy streams are just Java Streams: since Groovy runs on the JVM, you have full access to the entire 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.

Interview Questions on Groovy Streams

“Try each one aloud first; the phrasing you produce under mild pressure is what interviews measure.”

Q: A script builds def evens = orders.stream().filter { it.paid }, then adds more orders to the list, and only afterwards calls a terminal operation. Which orders end up in the result?

All of them, including the ones added after the stream was created, and that surprises people. A stream pipeline is lazy: filter records intent but touches nothing until a terminal operation like toList() or count() pulls elements through, and at that point it reads the backing collection as it exists then. Contrast with Groovy’s findAll, which is eager and snapshots results immediately. The deeper trap: structurally modifying the source between stream creation and consumption can throw ConcurrentModificationException, so the honest rule is create the stream, consume it, and do not touch the source in between.

Q: To speed up a Gradle task, someone swaps stream() for parallelStream() and collects results by adding to a shared list inside forEach. Totals are now wrong, but only sometimes. Why?

Because parallelStream runs the closure on many threads, and an ordinary ArrayList is not thread-safe: concurrent add calls lose elements or throw, intermittently, which is why it only fails sometimes. Side effects in parallel pipelines are the bug, not parallelism itself. The fix is making the pipeline pure: let the stream do the accumulation with collect(Collectors.toList()) or a reduction, which are designed to merge per-thread results safely. And worth saying in a build context: parallel streams share the common ForkJoinPool, so a parallel stream inside Gradle or Jenkins can starve other work.

Q: A log-analysis script does new File(path).readLines().findAll { it.contains('ERROR') } and dies with OutOfMemoryError on a 10 GB file. What is the streams-flavored fix?

readLines() materializes every line of the file as a list before findAll even starts, so the heap holds 10 GB of strings. The lazy alternative is Files.lines(path), which streams lines one at a time: Files.lines(path).filter { it.contains('ERROR') }.count() keeps memory flat no matter the file size. Two details that earn credit: wrap it in a try-with-resources or withCloseable because Files.lines holds the file handle until closed, and Groovy’s own eachLine is the GDK equivalent when you want lazy iteration without a stream pipeline.

Q: After a refactor from tags.findAll { it.active } to tags.stream().filter { it.active }.collect(Collectors.toList()), duplicate tags start appearing downstream. The data did not change. What did?

The container type. Groovy’s findAll preserves the source collection’s kind: on a Set it returns a Set, so duplicates were being collapsed for free. The stream pipeline was told to collect into a List, so the deduplication silently disappeared. Fixes: collect with Collectors.toSet(), or add distinct() to the pipeline if order matters. The interview point is that GDK methods carry Groovy semantics, type preservation here, while a stream’s output shape is entirely up to the collector you choose, and refactors between the two must re-state those guarantees explicitly.

Q: A numeric hot loop sums a field over millions of records. A teammate benchmarks records.collect { it.amount }.sum() against a stream version and the stream wins by a wide margin. What explains the gap?

Two compounding costs on the Groovy side: collect materializes a full intermediate list, and every element is a boxed object flowing through dynamic closure dispatch. The stream version, especially mapToLong { it.amount }.sum(), fuses the steps with no intermediate collection and runs on a primitive LongStream with no boxing at all, which is something GDK collection methods cannot do. If you want to stay idiomatic Groovy, records.sum { it.amount } at least drops the intermediate list, and @CompileStatic narrows the dispatch cost. For genuinely hot numeric paths, primitive streams are the right tool.

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 *