Groovy 5.0 New Features – 12 Tested Examples of What Changed

Groovy 5 new features with 12+ examples. Covers GINQ, sealed classes, records, switch expressions, pattern matching, and migration tips from Groovy 4.x.

“Every major version bump is a conversation between the language designers and the community. Groovy 5 is a particularly good conversation.”

Last Updated: July 2026 | Tested on: Groovy 5.0.6, Java 17+ | Difficulty: Intermediate to Advanced | Reading Time: 28 minutes

If you’ve been working with Groovy for any length of time, you’ve probably heard the buzz around Groovy 5 new features. This is not just a minor bump. Groovy 5.0 represents a significant leap forward – it modernizes the JDK baseline (11 minimum at runtime, 17+ recommended), drops legacy baggage that has been deprecated for years, and introduces language features that make your code shorter, safer, and more expressive.

This post gives you a clear picture of what’s new in Groovy 5.0, what broke (and how to fix it), and why upgrading is worth the effort. We’ll walk through 12 practical, tested examples covering GINQ queries, sealed classes, records, switch expressions, enhanced type checking, pattern matching, new GDK methods, and more.

If you’re comfortable with basic Groovy syntax and have been using Groovy 4.x, you’re in the right spot. If you need a refresher on type checking, take a quick look at our Groovy CompileStatic and TypeChecked guide first.

The diagram below shows how a Groovy 5 arrow-syntax switch expression resolves its cases, walking the labels from top to bottom, testing each closure guard or type or value with isCase, and returning the first arm that matches before falling back to default:

YesNo, it is a valueor a typeYesNoYes, type or value fitsNoNo labels left,default presentNo labels left,no defaultswitch(subject) evaluatedTake next case label,top to bottomLabel is a closureguard?guard(subject)returns true?label.isCase(subject)matches?Run this arm,return its valueRun the default armResult is null(no arm runs)Whole switch is oneexpression: assign the resultGroovy 5 Switch Expression: Case Resolution Order and isCase Dispatch

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

What Changed in Groovy 5.0?

Groovy 5.0 is the next major release after the 4.x line, and it builds on the foundation laid by Groovy 4’s package renaming and modularization. According to the official Groovy changelogs, the headline changes fall into several categories.

Key Changes at a Glance:

  • Java 17+ baseline – Groovy 5 pairs best with JDK 17+, unlocking sealed classes, records, and other modern JVM features natively
  • GINQ (Groovy-Integrated Query) – SQL-like query syntax for collections, now fully stable and optimized
  • Sealed classes and interfaces – restrict which classes can extend or implement your types
  • Records support – immutable data carriers with minimal boilerplate
  • Switch expressions with arrow syntax – more concise switch statements that return values
  • Enhanced type checking – smarter inference, better error messages, and improved @CompileStatic support
  • Pattern matching improvements – destructuring and type guards in switch cases
  • New GDK methods – fresh additions to the Groovy Development Kit on collections, strings, and I/O
  • JPMS module system support – Groovy jars now ship with proper module descriptors
  • Removed deprecated features – old APIs that were deprecated in Groovy 3.x and 4.x are gone

Why Upgrade to Groovy 5?

You might be thinking, “My Groovy 4.x code works fine. Why bother?” Here’s the honest answer: Groovy 5 is not just about shiny new syntax. It’s about staying current with the JVM ecosystem.

  • Java 17 LTS alignment – Java 17 is a widely deployed LTS, with 21 and 25 the newer LTS releases. Groovy 5 fully uses its features instead of emulating them. This means better performance and tighter integration.
  • GINQ replaces verbose collection pipelines – If you’ve ever chained five collect, findAll, and groupBy calls together, GINQ gives you a readable SQL-like alternative. Check out our guide on GINQ queries for the full picture.
  • Safer code with sealed types and records – Model your domain more precisely. Sealed classes tell the compiler exactly which subtypes exist. Records enforce immutability without the boilerplate.
  • Framework support – Grails 7 and Micronaut 4 ship on Groovy 4.x today, with Groovy 5 adoption on their roadmaps. Staying on 4.x will eventually lock you out of library updates.
  • Cleanup – Removed deprecated APIs mean a leaner, faster runtime with fewer surprises.

12 Practical Examples of Groovy 5 New Features

Let’s get hands-on. Every example below has been tested on Groovy 5.0.6 with Java 17+. We’ll cover the most impactful Groovy 5.0 features one by one.

Example 1: GINQ – Basic Collection Query

What we’re doing: Using GINQ (Groovy-Integrated Query) to filter and transform a list of maps, SQL-style. No database needed – this runs against plain Groovy collections.

Example 1: GINQ Basic Query

def employees = [
    [name: 'Alice',  dept: 'Engineering', salary: 95000],
    [name: 'Bob',    dept: 'Marketing',   salary: 72000],
    [name: 'Carol',  dept: 'Engineering', salary: 110000],
    [name: 'Dave',   dept: 'Marketing',   salary: 68000],
    [name: 'Eve',    dept: 'Engineering', salary: 105000]
]

// GINQ query - feels like SQL but runs on collections
def result = GQ {
    from e in employees
    where e.dept == 'Engineering'
    orderby e.salary in desc
    select e.name, e.salary
}

result.each { row ->
    println "${row[0]} earns \$${row[1]}"
}

Output

Carol earns $110000
Eve earns $105000
Alice earns $95000

What happened here: GQ { } is the GINQ entry point. The syntax mirrors SQL: from defines the data source, where filters, orderby sorts, and select projects columns. GINQ was introduced in Groovy 4 but is now fully stable and optimized in Groovy 5. For the complete GINQ guide, see our GINQ SQL-like Collection Queries post.

Example 2: GINQ – Joins and Aggregations

What we’re doing: Performing a join between two collections and using aggregate functions, just like a SQL JOIN with GROUP BY.

Example 2: GINQ Joins and Aggregations

def orders = [
    [id: 1, customerId: 101, amount: 250.00],
    [id: 2, customerId: 102, amount: 175.50],
    [id: 3, customerId: 101, amount: 320.00],
    [id: 4, customerId: 103, amount: 89.99],
    [id: 5, customerId: 102, amount: 410.00]
]

def customers = [
    [id: 101, name: 'Alice'],
    [id: 102, name: 'Bob'],
    [id: 103, name: 'Carol']
]

// Join orders with customers and compute totals
def report = GQ {
    from o in orders
    join c in customers on o.customerId == c.id
    groupby c.name
    orderby sum(o.amount) in desc
    select c.name, sum(o.amount) as totalSpent, count(o.id) as orderCount
}

report.each { row ->
    println "${row.name}: \$${row.totalSpent} (${row.orderCount} orders)"
}

Output

Bob: $585.50 (2 orders)
Alice: $570.00 (2 orders)
Carol: $89.99 (1 orders)

What happened here: GINQ supports join, groupby, and aggregate functions like sum() and count(). This is the kind of query that would require a verbose chain of collectEntries, groupBy, and sum calls the traditional way. GINQ makes intent immediately clear.

Example 3: Sealed Classes and Interfaces

What we’re doing: Defining a sealed interface to restrict which classes can implement it. This is powerful for modeling finite state machines, command patterns, or domain events.

Example 3: Sealed Classes

import groovy.transform.Sealed

@Sealed(permittedSubclasses = [Circle, Rectangle, Triangle])
interface Shape {
    double area()
}

class Circle implements Shape {
    double radius
    double area() { Math.PI * radius * radius }
    String toString() { "Circle(r=${radius})" }
}

class Rectangle implements Shape {
    double width, height
    double area() { width * height }
    String toString() { "Rectangle(${width}x${height})" }
}

class Triangle implements Shape {
    double base, height
    double area() { 0.5 * base * height }
    String toString() { "Triangle(b=${base}, h=${height})" }
}

// Use the sealed hierarchy
def shapes = [
    new Circle(radius: 5.0),
    new Rectangle(width: 4.0, height: 6.0),
    new Triangle(base: 3.0, height: 8.0)
]

shapes.each { shape ->
    println "${shape} -> area = ${String.format('%.2f', shape.area())}"
}

Output

Circle(r=5.0) -> area = 78.54
Rectangle(4.0x6.0) -> area = 24.00
Triangle(b=3.0, h=8.0) -> area = 12.00

What happened here: The @Sealed annotation restricts which classes can implement Shape. If someone tries to create a Pentagon implements Shape outside the permitted list, the compiler catches it. This is excellent for domain modeling where you want exhaustive pattern matching. Groovy’s sealed types align with Java 17’s sealed classes, and if you’re familiar with Groovy traits, you’ll appreciate how sealed interfaces complement that feature.

Example 4: Records – Immutable Data Carriers

What we’re doing: Defining record classes – concise, immutable data holders that automatically get equals(), hashCode(), toString(), and accessor methods.

Example 4: Records

// Groovy record - compact and immutable
record Point(int x, int y) {}

record Person(String name, int age) {
    // You can add custom methods to records
    String greet() { "Hi, I'm ${name} and I'm ${age} years old." }
}

// Create instances
def p1 = new Point(3, 7)
def p2 = new Point(3, 7)
def p3 = new Point(1, 2)

println p1                      // toString auto-generated
println "p1 == p2: ${p1 == p2}" // equals auto-generated
println "p1 == p3: ${p1 == p3}"
println "x = ${p1.x()}, y = ${p1.y()}" // accessor methods

def alice = new Person('Alice', 30)
println alice
println alice.greet()

// Records work great in collections
def points = [new Point(1, 1), new Point(2, 3), new Point(1, 1)]
println "Unique points: ${points.toSet()}"

Output

Point[x=3, y=7]
p1 == p2: true
p1 == p3: false
x = 3, y = 7
Person[name=Alice, age=30]
Hi, I'm Alice and I'm 30 years old.
Unique points: [Point[x=1, y=1], Point[x=2, y=3]]

What happened here: The record keyword creates a class with final fields, a canonical constructor, and auto-generated equals(), hashCode(), and toString(). This replaces the need for @Immutable in many cases – though @Immutable still has its place for more complex scenarios. Records are value-based, so two records with the same field values are equal.

Example 5: Switch Expressions with Arrow Syntax

What we’re doing: Using the new arrow-style switch expressions that return values directly, without fall-through or explicit break statements.

Example 5: Switch Expressions

// Classic switch vs new switch expression
def httpStatus = 404

// New switch expression with arrow syntax
def message = switch(httpStatus) {
    case 200 -> 'OK'
    case 201 -> 'Created'
    case 301 -> 'Moved Permanently'
    case 400 -> 'Bad Request'
    case 401 -> 'Unauthorized'
    case 403 -> 'Forbidden'
    case 404 -> 'Not Found'
    case 500 -> 'Internal Server Error'
    default  -> "Unknown status: ${httpStatus}"
}
println "HTTP ${httpStatus}: ${message}"

// Switch expressions work with types too
def classifyValue(value) {
    return switch(value) {
        case Integer -> "Integer: ${value}"
        case String  -> "String of length ${value.length()}"
        case List    -> "List with ${value.size()} elements"
        case null    -> "It's null!"
        default      -> "Unknown type: ${value.getClass().name}"
    }
}

println classifyValue(42)
println classifyValue('hello')
println classifyValue([1, 2, 3])
println classifyValue(null)

Output

HTTP 404: Not Found
Integer: 42
String of length 5
List with 3 elements
It's null!

What happened here: The arrow (->) syntax eliminates fall-through entirely. Each case maps directly to a result. The whole switch is an expression, so you can assign it to a variable. This is much cleaner than the old switch/case/break pattern. Groovy’s switch has always been powerful (matching against types, ranges, closures), and the arrow syntax makes it even better.

Example 6: Enhanced Type Checking Improvements

What we’re doing: Showing how @CompileStatic has gotten smarter in Groovy 5, with better type inference and more helpful error messages.

Example 6: Enhanced Type Checking

import groovy.transform.CompileStatic

@CompileStatic
class TypeSafeDemo {

    // Groovy 5 infers generics better
    static List<String> filterLongNames(List<String> names) {
        // Type inference carries through the chain
        names.findAll { it.length() > 4 }
             .collect { it.toUpperCase() }
             .sort()
    }

    // var keyword for local type inference
    static void demonstrateVar() {
        var greeting = 'Hello, Groovy 5!'    // inferred as String
        var numbers = [1, 2, 3, 4, 5]        // inferred as List<Integer>
        var map = [name: 'Alice', age: '30']  // inferred as Map

        println "greeting class: ${greeting.getClass().name}"
        println "numbers class: ${numbers.getClass().name}"
        println "map class: ${map.getClass().name}"

        // Groovy 5 catches type errors at compile time
        // var x = greeting + numbers  // Would fail at compile time with @CompileStatic
    }

    static void main(String[] args) {
        def result = filterLongNames(['Alice', 'Bob', 'Charlie', 'Dave', 'Eleanor'])
        println "Filtered: ${result}"
        demonstrateVar()
    }
}

TypeSafeDemo.main(null)

Output

Filtered: [ALICE, CHARLIE, ELEANOR]
greeting class: java.lang.String
numbers class: java.util.ArrayList
map class: java.util.LinkedHashMap

What happened here: @CompileStatic in Groovy 5 is smarter about inferring types through method chains. The var keyword (introduced in Groovy 3, refined in 5) lets you write concise code while retaining type safety. For a deeper look at static compilation, see our CompileStatic and TypeChecked guide.

Example 7: Pattern Matching Enhancements

What we’re doing: Using improved pattern matching in switch expressions to destructure and guard against types in a single statement.

Example 7: Pattern Matching

// Pattern matching with type checks and guards
def describeValue(obj) {
    switch(obj) {
        case { it instanceof Integer && it > 0 }  -> "Positive integer: ${obj}"
        case { it instanceof Integer && it < 0 }  -> "Negative integer: ${obj}"
        case Integer                              -> "Zero"
        case { it instanceof String && it.empty } -> "Empty string"
        case String                               -> "String: '${obj}' (${obj.length()} chars)"
        case { it instanceof List && it.empty }   -> "Empty list"
        case List                                 -> "List with ${obj.size()} items: ${obj}"
        case null                                 -> "null value"
        default                                   -> "Other: ${obj}"
    }
}

println describeValue(42)
println describeValue(-7)
println describeValue(0)
println describeValue('')
println describeValue('Groovy 5')
println describeValue([])
println describeValue([1, 2, 3])
println describeValue(null)
println describeValue(3.14)

Output

Positive integer: 42
Negative integer: -7
Zero
Empty string
String: 'Groovy 5' (8 chars)
Empty list
List with 3 items: [1, 2, 3]
null value
Other: 3.14

What happened here: Groovy’s switch matches each case with isCase(), so a bare class like Integer checks the type while a closure like { it instanceof Integer && it > 0 } acts as a guard against the value bound to it. Combined with the arrow syntax, you get concise type-plus-condition branches without nested if blocks. Note that Groovy does not use Java’s case Integer i binding form in the label itself. You refer to the subject directly (here obj) or through it inside a closure case.

Example 8: New GDK Methods on Collections

What we’re doing: Exploring new methods added to the Groovy Development Kit in version 5.x, particularly on collections and iterables.

Example 8: New GDK Methods

// windowedSlice - sliding window over a list
def numbers = [1, 2, 3, 4, 5, 6, 7, 8]
def windows = numbers.collate(3, 1, false)
println "Sliding windows of 3: ${windows}"

// takeRight and dropRight - grab or skip elements from the end
def recent = [1, 2, 3, 4, 5, 6, 7, 8]
println "Last 3 (takeRight): ${recent.takeRight(3)}"
println "Drop last 3:        ${recent.dropRight(3)}"

// indexed() - pair each element with its index
def fruits = ['apple', 'banana', 'cherry']
fruits.indexed().each { idx, fruit ->
    println "  [${idx}] ${fruit}"
}

// collectMany with index
def words = ['hello', 'world']
def chars = words.collectMany { it.toList() }
println "All chars: ${chars}"

// average() on a numeric collection
def scores = [85, 92, 78, 96, 88]
println "Average score: ${scores.average()}"

// tap - like with() but returns the original object
def config = [:].tap {
    put('host', 'localhost')
    put('port', 8080)
    put('debug', true)
}
println "Config: ${config}"

Output

Sliding windows of 3: [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7], [6, 7, 8]]
Last 3 (takeRight): [6, 7, 8]
Drop last 3:        [1, 2, 3, 4, 5]
  [0] apple
  [1] banana
  [2] cherry
All chars: [h, e, l, l, o, w, o, r, l, d]
Average score: 87.8
Config: [host:localhost, port:8080, debug:true]

What happened here: Groovy 5 continues to expand the GDK – the set of convenience methods added to standard Java classes. Methods like takeRight, dropRight, average(), and tap reduce the need for manual loops and temporary variables. The collate method with step=1 gives you sliding windows, which is handy for time-series data or moving averages.

Example 9: Java 17+ Compatibility – Text Blocks

What we’re doing: Taking advantage of Java 17+ features that are now directly available in Groovy 5, including interoperability with Java text blocks and sealed classes.

Example 9: Java 17+ Compatibility

// Groovy already had multiline strings, but now Java's text blocks
// work directly in mixed Groovy/Java projects

// Groovy's triple-quoted strings (always supported)
def groovyStyle = '''
    SELECT name, age
    FROM users
    WHERE active = true
    ORDER BY name
'''.stripIndent().trim()

println "Groovy multiline:\n${groovyStyle}"
println()

// Using Java record classes from Groovy 5
// Java records defined in Java files are fully interoperable
record Coordinate(double lat, double lng) {
    String format() { "${lat}N, ${lng}E" }
}

def locations = [
    new Coordinate(40.7128, -74.0060),
    new Coordinate(51.5074, -0.1278),
    new Coordinate(35.6762, 139.6503)
]

locations.each { coord ->
    println "Location: ${coord.format()}"
}

// instanceof pattern variable (Java 16+, now native in Groovy 5)
def items = ['hello', 42, [1, 2], 3.14, null]
items.each { item ->
    if (item instanceof String s) {
        println "String: ${s.toUpperCase()}"
    } else if (item instanceof Number n) {
        println "Number doubled: ${n * 2}"
    } else {
        println "Other: ${item}"
    }
}

Output

Groovy multiline:
SELECT name, age
    FROM users
    WHERE active = true
    ORDER BY name

Location: 40.7128N, -74.006E
Location: 51.5074N, -0.1278E
Location: 35.6762N, 139.6503E
String: HELLO
Number doubled: 84
Other: [1, 2]
Number doubled: 6.28
Other: null

What happened here: With Java 17 as the baseline, Groovy 5 gets native access to instanceof pattern variables (binding the matched value to a typed variable in one step). Java records and Groovy records are interoperable. While Groovy already had multiline strings, the alignment with Java 17 means mixed codebases work more smoothly than ever. Two quick notes on the output: the no-argument stripIndent() removes only the shared leading indentation, and here the first content line sets that baseline, so the later SQL lines keep their relative indent. Also, the [1, 2] list is neither a String nor a Number, so it falls through to the else branch and prints as Other.

Example 10: Module System Support

What we’re doing: Understanding how Groovy 5 jars ship with proper JPMS (Java Platform Module System) module descriptors and what that means for your projects.

Example 10: Module System Awareness

// Groovy 5 and the Java Module System (JPMS)
println "Groovy version: ${GroovySystem.version}"
println "Java version:   ${System.getProperty('java.version')}"
println()
println "Key Groovy 5 module facts:"
println "  - Jars declare Automatic-Module-Name (e.g. org.apache.groovy.json)"
println "  - No module-info.class descriptors: automatic, not explicit, modules"
println "  - org.apache.groovy is the Maven groupId (since Groovy 4)"
println "  - org.codehaus.groovy.* compiler and AST packages remain valid API"

Output

Groovy version: 5.0.6
Java version:   17.0.11

Key Groovy 5 module facts:
  - Jars declare Automatic-Module-Name (e.g. org.apache.groovy.json)
  - No module-info.class descriptors: automatic, not explicit, modules
  - org.apache.groovy is the Maven groupId (since Groovy 4)
  - org.codehaus.groovy.* compiler and AST packages remain valid API

What happened here: Groovy 5 fully embraces the Java Platform Module System. Each Groovy jar (groovy-json, groovy-xml, groovy-sql, etc.) now contains a proper module-info.class file. This means your modular Java applications can cleanly declare dependencies on specific Groovy modules rather than requiring the entire distribution. The canonical package namespace is now org.apache.groovy.* – the old org.codehaus.groovy.* references are internal. The Java version, vendor, and the exact list of loaded packages depend on your JDK build and which classes have been touched, so your run may show different values there.

Example 11: New String and I/O GDK Methods

What we’re doing: Exploring new convenience methods added to String and File/Path classes in Groovy 5.

Example 11: String and I/O GDK

// String enhancements
def text = '  Hello, Groovy 5!  '
println "strip:       '${text.strip()}'"
println "stripLeading:'${text.stripLeading()}'"
println "stripTrailing:'${text.stripTrailing()}'"
println "isBlank:     ${'   '.isBlank()}"
println "repeat:      ${'ab'.repeat(3)}"

// lines() returns a stream of lines
def multiline = "line one\nline two\nline three"
def lineList = multiline.readLines()
println "Lines: ${lineList}"

// takeRight and dropRight (GDK additions)
def code = 'GROOVY-5.0'
println "take(6):     '${code.take(6)}'"
println "drop(7):     '${code.drop(7)}'"
println "takeBetween: '${code.takeBetween('-', '.')}'"

// File/Path convenience methods
def tempFile = File.createTempFile('groovy5-demo', '.txt')
tempFile.text = 'Groovy 5 rocks!\nLine 2\nLine 3'

// Read all lines
println "\nFile lines: ${tempFile.readLines()}"

// withReader - auto-closes
tempFile.withReader { reader ->
    println "First line: ${reader.readLine()}"
}

// Cleanup
tempFile.delete()
println "Temp file cleaned up: ${!tempFile.exists()}"

Output

strip:       'Hello, Groovy 5!'
stripLeading:'Hello, Groovy 5!  '
stripTrailing:'  Hello, Groovy 5!'
isBlank:     true
repeat:      ababab
Lines: [line one, line two, line three]
take(6):     'GROOVY'
drop(7):     '5.0'
takeBetween: '5'

File lines: [Groovy 5 rocks!, Line 2, Line 3]
First line: Groovy 5 rocks!
Temp file cleaned up: true

What happened here: Java 11+ string methods like strip(), isBlank(), and repeat() are available when running on Java 17+. Groovy’s own GDK additions like take(), drop(), and takeBetween() complement them nicely. The file I/O methods like readLines() and withReader continue to be some of the best reasons to choose Groovy for scripting tasks.

Example 12: Real-World Migration – Before and After

What we’re doing: Showing a realistic code transformation from Groovy 4.x style to idiomatic Groovy 5.0.6, using multiple new features together.

Example 12: Groovy 4.x Style (Before)

// ===== GROOVY 4.x STYLE =====
import groovy.transform.Immutable
import groovy.transform.CompileStatic

// Had to use @Immutable annotation
@Immutable
class ProductOld {
    String name
    BigDecimal price
    String category
}

// Complex collection processing with chained closures
def products = [
    new ProductOld('Laptop', 999.99, 'Electronics'),
    new ProductOld('Desk', 299.99, 'Furniture'),
    new ProductOld('Mouse', 29.99, 'Electronics'),
    new ProductOld('Chair', 449.99, 'Furniture'),
    new ProductOld('Monitor', 549.99, 'Electronics'),
    new ProductOld('Lamp', 39.99, 'Furniture')
]

// Verbose: group by category, filter expensive, get names
def expensiveByCategory = products
    .findAll { it.price > 100 }
    .groupBy { it.category }
    .collectEntries { cat, items ->
        [cat, items.collect { it.name }.sort()]
    }

println "Old style: ${expensiveByCategory}"

// Old switch with break
def classify(BigDecimal price) {
    switch(price) {
        case { it < 50 }:
            return 'Budget'
        case { it < 200 }:
            return 'Mid-range'
        case { it < 500 }:
            return 'Premium'
        default:
            return 'Luxury'
    }
}

products.each { p ->
    println "  ${p.name}: ${classify(p.price)}"
}

Output

Old style: [Electronics:[Laptop, Monitor], Furniture:[Chair, Desk]]
  Laptop: Luxury
  Desk: Premium
  Mouse: Budget
  Chair: Premium
  Monitor: Luxury
  Lamp: Budget

Example 12b: Groovy 5.0.6 Style (After)

// ===== GROOVY 5.x STYLE =====

// Records replace @Immutable for simple data carriers
record Product(String name, BigDecimal price, String category) {}

def products = [
    new Product('Laptop', 999.99, 'Electronics'),
    new Product('Desk', 299.99, 'Furniture'),
    new Product('Mouse', 29.99, 'Electronics'),
    new Product('Chair', 449.99, 'Furniture'),
    new Product('Monitor', 549.99, 'Electronics'),
    new Product('Lamp', 39.99, 'Furniture')
]

// GINQ replaces the verbose chain
def expensiveByCategory = GQ {
    from p in products
    where p.price() > 100
    groupby p.category() as category
    orderby category
    select category, agg(_g.stream().map(r -> r.p.name()).toList()) as names
}

expensiveByCategory.each { row ->
    println "${row.category}: ${row.names.toSorted()}"
}

// Switch expression replaces switch/break
def classify = { BigDecimal price -> switch(price) {
    case { it < 50 }   -> 'Budget'
    case { it < 200 }  -> 'Mid-range'
    case { it < 500 }  -> 'Premium'
    default             -> 'Luxury'
}}

products.each { p ->
    println "  ${p.name()}: ${classify(p.price())}"
}

Output

Electronics: [Laptop, Monitor]
Furniture: [Chair, Desk]
  Laptop: Luxury
  Desk: Premium
  Mouse: Budget
  Chair: Premium
  Monitor: Luxury
  Lamp: Budget

What happened here: The Groovy 5.0.6 version is noticeably cleaner. record replaces @Immutable for simple data classes. GINQ replaces the findAll/groupBy/collectEntries chain with a readable SQL-like query. The switch expression eliminates explicit return and break statements. Same results, less noise.

Migrating from Groovy 4.x to 5.x

If you’re planning to upgrade an existing Groovy 4.x project, here’s a practical checklist. Most projects will migrate smoothly, but there are a few things to watch for.

Step 1: Upgrade Your JDK

Groovy 5 requires Java 17 or higher. If you’re still on Java 11 or 8, that’s the first thing to address. Java 17 is an LTS release with excellent ecosystem support, so this should be a manageable upgrade for most teams.

Check Java Version

java -version
# Required: 17.x or higher

Step 2: Update Your Build File

Gradle Build Update

// build.gradle - Before (Groovy 4.x)
dependencies {
    implementation 'org.apache.groovy:groovy:4.0.18'
    implementation 'org.apache.groovy:groovy-json:4.0.18'
}

// build.gradle - After (Groovy 5.0.6)
dependencies {
    implementation 'org.apache.groovy:groovy:5.0.6'
    implementation 'org.apache.groovy:groovy-json:5.0.6'
    implementation 'org.apache.groovy:groovy-ginq:5.0.6' // if using GINQ
}

Step 3: Fix Breaking Changes

The most common issues you’ll encounter:

  • Removed deprecated APIs – Methods that were deprecated in Groovy 3.x/4.x are gone. Check your IDE warnings or run a compile to find them.
  • Package renames completed – If you still have any org.codehaus.groovy imports, replace them with org.apache.groovy. This migration started in Groovy 4, but Groovy 5 enforces it.
  • DGM method signature changes – Some Default Groovy Methods (DGM) have tighter generics. You may see compile errors in @CompileStatic code that worked before.
  • AST transformation updates – If you wrote custom AST transformations, check compatibility with the new Groovy 5 AST API.

Step 4: Adopt New Features Gradually

You don’t have to rewrite everything at once. Start with the easy wins:

  • Replace @Immutable with record for simple data classes
  • Use switch expressions in new code
  • Try GINQ for complex collection queries
  • Add @Sealed to class hierarchies where exhaustive matching makes sense

Performance Improvements

Groovy 5 delivers measurable performance gains in several areas. Here’s what the Groovy team has focused on:

  • Faster method dispatch – The meta-object protocol (MOP) has been optimized for common call patterns, reducing overhead on dynamic method calls by 15-25% in typical benchmarks.
  • Improved @CompileStatic code generation – Statically compiled Groovy code now generates bytecode that is closer to what javac produces, making it nearly as fast as Java in most scenarios.
  • GINQ query optimization – GINQ queries are compiled to efficient bytecode at compile time, not interpreted at runtime. Joins and aggregations use hash-based strategies where possible.
  • Startup time – Groovy 5 with Java 17 benefits from JVM improvements like CDS (Class Data Sharing) for faster cold starts, especially relevant in serverless and CLI contexts.
  • Memory footprint – Records and sealed classes use less memory than their annotation-based counterparts since they map directly to JVM-level constructs rather than being emulated through AST transformations.

If you’re running performance-sensitive code, the combination of @CompileStatic and Java 17+ is the sweet spot. For scripting and dynamic Groovy, the MOP improvements ensure that your code runs snappier without any changes on your part.

Deprecated Features Removed

Groovy 5 cleaned house. If you’ve been ignoring deprecation warnings, now’s the time to pay attention. Here are the key removals:

Removed FeatureDeprecated SinceReplacement
org.codehaus.groovy.* public APIsGroovy 4.0org.apache.groovy.*
@Newify(auto=false) old styleGroovy 3.0@Newify(pattern=...)
Legacy GroovyClassLoader.parseClass variantsGroovy 4.0Updated method signatures
Old JsonBuilder constructor formsGroovy 4.0Current StreamingJsonBuilder API
AntBuilder in coreGroovy 4.0groovy-ant optional module
Legacy grab resolver configurationsGroovy 4.0Updated @Grab and @GrabResolver

The general pattern is clear: anything that was part of the old org.codehaus.groovy namespace and was deprecated during the Apache migration is now gone. If your IDE is showing deprecation warnings on Groovy 4.x, fix them before upgrading.

Edge Cases and Best Practices

Records vs @Immutable – When to Use Which

Records are great for simple data carriers, but @Immutable still wins in some cases:

  • Use record when you want a simple value type with equals/hashCode/toString, and you don’t need inheritance
  • Use @Immutable when you need copyWith, when your fields include mutable types that need defensive copying, or when you need to extend another class
  • Records cannot extend other classes (they implicitly extend java.lang.Record), but they can implement interfaces

GINQ Gotchas

  • GINQ runs on in-memory collections. It is not a database query engine. For large datasets (100K+ elements), traditional loops or streams may be more memory-efficient.
  • GINQ requires the groovy-ginq module. Make sure it’s on your classpath if you’re not using the full Groovy distribution.
  • GINQ joins default to nested loop joins. For large collections, consider pre-indexing data with maps for better performance.

Sealed Classes Tips

  • Sealed classes work best when all permitted subclasses are defined in the same source file or compilation unit
  • Combine sealed interfaces with records for a powerful algebraic data type pattern (like Kotlin’s sealed classes with data classes)
  • The compiler can warn you about non-exhaustive switch expressions over sealed types – take advantage of this for safer code

Conclusion

Groovy 5 new features bring the language firmly into the modern JVM era. The Java 17 baseline unlocks records, sealed classes, and pattern matching natively. GINQ gives you SQL-like querying over collections. Switch expressions clean up conditional logic. And the module system support makes Groovy play nicely with the Java module world.

The migration from Groovy 4.x is simple for most projects: upgrade your JDK, update your dependency version, fix any deprecated API usages, and you’re good. Then start adopting the new features at your own pace – records for new data classes, GINQ for complex queries, sealed types for domain modeling.

Grails applications, Jenkins CI/CD pipelines, everyday scripting tasks – Groovy 5.0 makes your code cleaner, safer, and more performant across all of them. That’s a version bump worth making.

Summary

  • Java 17 is required – Groovy 5 won’t run on older JDKs, but you get native access to modern JVM features
  • GINQ replaces verbose collection pipelines with readable SQL-like syntax
  • Records provide immutable data classes with zero boilerplate
  • Sealed classes let you model closed type hierarchies for exhaustive pattern matching
  • Switch expressions with arrow syntax eliminate fall-through bugs and produce cleaner code
  • Enhanced @CompileStatic means better type inference and error messages
  • Migration from Groovy 4.x is mostly painless – fix deprecations, update JDK, bump version

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 Java Interoperability – Practical Integration Guide

Frequently Asked Questions

What are the main new features in Groovy 5.0?

Groovy 5.0 introduces records, sealed classes and interfaces, switch expressions with arrow syntax, enhanced pattern matching, improved type checking with @CompileStatic, stabilized GINQ (SQL-like collection queries), new GDK methods, Automatic-Module-Name JPMS entries, and a modernized JDK baseline. It also removes several APIs that were deprecated in Groovy 3.x and 4.x.

Does Groovy 5 require Java 17?

Mostly no: Groovy 5 supports JDK 11 as the minimum runtime, and JDK 17+ is required only to build Groovy itself. Native records and sealed-class bytecode still need a JDK that has those features (17+). The Java 17 baseline allows Groovy to natively use sealed classes, records, text blocks, and other modern JVM features without emulation.

Is Groovy 5 backward compatible with Groovy 4?

Groovy 5 is largely backward compatible with Groovy 4.x, but it removes APIs that were deprecated in Groovy 3.x and 4.x, particularly the old org.codehaus.groovy public packages. Most projects will compile without changes if they addressed deprecation warnings. The main migration tasks are upgrading to Java 17+, updating the Groovy dependency version, and fixing any removed API usages.

What is GINQ in Groovy 5?

GINQ (Groovy-Integrated Query) is a SQL-like query syntax for Groovy collections. It lets you write from, where, join, groupby, orderby, and select clauses against in-memory lists and maps. GINQ was introduced in Groovy 4 as an incubating feature and is fully stable in Groovy 5. It replaces verbose chains of findAll, collect, and groupBy with readable, declarative queries.

Should I use records or @Immutable in Groovy 5?

Use record for simple, flat data carriers where you need equals, hashCode, and toString. Use @Immutable when you need features like copyWith, defensive copying of mutable fields, or inheritance from another class. Records cannot extend other classes (they extend java.lang.Record), but they can implement interfaces. For most new code in Groovy 5, records are the simpler choice.

Interview Questions on Groovy 5 New Features

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

Q: After bumping a Gradle project from Groovy 4 to Groovy 5, the build fails with ClassNotFoundException on an org.codehaus.groovy class, even though your own code never imports it. Where do you look?

At the dependencies, not your code. Groovy 5 removed public APIs deprecated since 3.x, including old org.codehaus.groovy package remnants, and the classic victims are third-party libraries and AST transform annotations compiled against those internals. So I would run a dependency report, find which library still references the removed class, and check whether it ships a Groovy-5-compatible release. If not, the choices are pinning that library behind an adapter, replacing it, or delaying the upgrade. The interview point: your compatibility surface includes everything on the classpath.

Q: A developer tries to use a record in a Jenkinsfile and gets a syntax error, then argues “but Groovy supports records now”. What is your explanation?

Language features belong to the runtime that executes the script, and Jenkins embeds its own, much older Groovy for pipelines, so record syntax simply does not exist there regardless of what you installed locally. This trips up a lot of people because the same file extension behaves differently across hosts: standalone Groovy 5 scripts, Gradle’s bundled Groovy, and the Jenkins pipeline engine are three different Groovy versions. Before using any new feature, confirm which Groovy the host embeds. For Jenkins, keep shared library code conservative or move the logic out of the pipeline runtime entirely.

Q: You are modeling payment events and a reviewer suggests a sealed interface with a switch expression instead of your class hierarchy plus instanceof chain. What do you gain, concretely?

Two things: a closed type hierarchy and exhaustiveness. Sealing PaymentEvent with permits Authorized, Captured, Refunded means no one can add a fourth subtype in another module without touching the seal, so the domain model is enforced by the compiler rather than by convention. Then a switch expression over the event returns a value with no fall-through, and when a new permitted subtype appears, the non-exhaustive switch surfaces immediately instead of silently hitting a default branch. That turns “we forgot to handle refunds” from a production bug into a compile-time task list.

Q: A Java teammate asks why you would declare a record in Groovy 5 when Java 17 already has records. Is there any actual difference?

Under the hood there is no difference: a Groovy record compiles to a genuine java.lang.Record subclass, so Java code consumes it with zero friction. What Groovy adds is ergonomics on top: named arguments when constructing, and optional generated helpers like copyWith, toMap, and toList controlled through @RecordOptions, which cover the “modify one field of an immutable value” workflow records famously lack in Java. So in a mixed codebase the record definition sits happily in Groovy while both languages share it. Same bytecode contract, less ceremony.

Q: Your team wants the Groovy 5 upgrade but the platform team caps production at Java 11. What do you tell the planning meeting?

That the upgrade is blocked until the JDK moves: Groovy 5 still runs on JDK 11 as its minimum runtime (17+ is only the build requirement for Groovy itself), so the JDK bump can be sequenced on its own timeline. So the realistic plan is to sequence the work, first migrate the runtime to 17, which is a well-trodden path with long-term support, then take Groovy 5 in a follow-up release. Meanwhile, stay on the latest Groovy 4.x line, which still receives fixes and runs fine on 11. Trying to cherry-pick Groovy 5 features onto an old JDK is the one answer that is simply wrong.

Previous in Series: Groovy Scripting and Automation – Practical Guide

Next in Series: Groovy Java Interoperability – Practical Integration Guide

Related Topics You Might Like:

This post is part of the Groovy 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 *