Release notes for Groovy 6.0
Groovy 6 builds upon existing features of earlier versions of Groovy. In addition, it incorporates numerous new features and streamlines various legacy aspects of the Groovy codebase.
NOTE for RC-1: RC-1 defaults to turning on a reflective cold dispatch tier. This is a setting currently needed for native images like GraalVM but changes what stack frames would be visible to logging libraries. This tier will be opt-in only for future releases. Users outside native scenarios may want to set the flag to opt-out for RC-1, or use the other workarounds mentioned like @CompileStatic. See GROOVY-12354 for more details. |

Highlights
Groovy 6 is a big release, so the headline changes are grouped below, each linking to a fuller section further down. A few themes tie them together: closing long-standing gaps with modern Java and Kotlin, growing the batteries-included standard library and modules that make Groovy such a productive scripting language, making concurrent code readable again, and — looking ahead — turning code into specifications that people and AI tools alike can reason about.
Native Async/Await (incubating)
Closes Groovy’s biggest concurrency gap: asynchronous code you read top to bottom instead of stitching together callbacks and futures — with virtual threads doing the work on JDK 21+.
-
Sequential-style concurrent code — no callbacks or
CompletableFuturechains. -
Automatic virtual threads on JDK 21+; cached thread pool fallback on JDK 17–20.
-
Generators with
yield return, Go-style channels,for await, structured concurrency viaAsyncScope.
Integrated Concurrency Toolkit (incubating)
Gathers high-level concurrency — agents, actors, dataflow, channels and parallel collections — into one coherent package, so you pick the right tool for the job instead of hand-rolling threads, and it’s callable from Java too. Previous users of GPars will find much of its functionality here, but now aligned with modern paradigms like virtual-thread and structured concurrency support.
-
Unified
groovy.concurrentpackage: agents, actors, dataflow variables, channels, and parallel collections. -
@ActiveObjectadds actor semantics to ordinary classes — no message protocols to hand-write. -
Parallel
Collectionmethods (collectParallel,findAllParallel,eachParallel, …) for CPU-bound work. -
ChannelSelectcovers the full CSP choice: mixed send/receive offers,whenguards,fair()/random()policies, and timer branches — with losing branches left untouched. -
Same APIs available to Java, Kotlin and other JVM languages via the standalone
groovy-concurrent-javamodule.
New Language Features
Small, sharp ergonomics that cut day-to-day friction and keep Groovy in step with modern Java and Kotlin.
-
valcontextual keyword for final declarations — a clean companion tovar. -
Module imports (JEP 511):
import module java.sqlcovers every exported package in one line even on JDK17. -
Additional destructuring e.g. with rest binders:
def (h, *t) = list. -
Compound-assignment operator overloading (
plusAssign,minusAssign, …) for efficient in-place mutation, even onfinalfields. -
Intersection-type cast for lambdas, method references and closures —
(Runnable & Serializable) () → …. -
AST transforms now valid on loop statements —
@Parallelfor-loops,@Invariant,@Decreases. -
Nested
copyWithfor@Immutable/@RecordType— dotted-path map keys and a transactional block form, with structural sharing preserved.
The release’s most forward-looking theme: contracts, purity and frame conditions make each method a self-describing, compile-checked specification — as useful to an AI assistant as to the next person reading the code.
-
New
NullCheckertype checker with an optional flow-sensitivestrictmode requiring no annotations. -
@Modifiesframe conditions and@Purepurity declarations, verified at compile time byModifiesCheckerandPurityChecker. -
CombinerCheckerverifies parallel-reduction combiners are associative, via new@Associative/@Reducerdeclarations. -
DOmacro forfor/do-style monadic comprehensions across the standard JVM carriers, FunctionalJava and Vavr control types, and user@Monadictypes — withMonadicCheckerandMonadicShapeCheckerenforcing the carrier shape at compile time. -
Loop invariants and termination measures via
@Invariant/@Decreases. -
Contracts (
@Requires,@Ensures,@Invariant) now also work in scripts. -
Each method becomes a self-contained specification — readable without descending into bodies.
-
Those verified declarations are machine-actionable — a foundation for AI skills and tooling, not just for reading.
Batteries-included scripting: reach for HTTP, CSV, Markdown or a Maven-backed @Grab right out of the box — reinforcing Groovy as the Python of the JVM.
-
groovy-concurrent-java— Standalone Java library exposing thegroovy.concurrenttoolkit; no Groovy runtime required. -
groovy-http-builder— HTTP client with imperative DSL and declarative@HttpBuilderClientinterface. Auto-parsed JSON, XML and HTML responses; typed return objects driven by interface signatures. -
groovy-csv— RFC 4180 CSV reading/writing with optional Jackson-backed typed parsing. -
groovy-markdown— CommonMark parser with section, code-block and table extraction helpers. -
groovy-grape-ivy—@GrabIvy backend, now its own optional module (previously bundled in core). -
groovy-grape-maven—@Grabpowered by Maven Resolver, alongside the existing Ivy engine. -
groovy-reactor/groovy-rxjava—awaitandfor awaitover reactiveMono/Flux/Observabletypes. -
groovy-test-junit6— Run JUnit Jupiter 6 tests as Groovy scripts.
Existing Module Improvements
Less ceremony for querying and parsing data, with more consistency across formats.
-
GINQ provides a cleaner
groupby … intowhich binds a group to a named variable with aggregate access, and gains SQL-style set operators:union,intersect,minus,unionall. -
Consistent typed parsing across JSON, CSV, TOML, YAML and XML modules.
A Focus on Performance
Groovy 6 spends a lot of effort below the surface: dynamic dispatch that stays fast when metaclasses change, fewer allocations on hot paths, and less generated bytecode — with no changes to semantics.
-
Scoped
invokedynamicinvalidation: a metaclass change no longer deoptimizes every call site in the JVM. Call sites now guard on aSwitchPointowned by the receiver’s metaclass, so churn on one class leaves hot call sites elsewhere linked and inlined — the payoff is largest for frameworks like Grails and for test suites that install and remove metaclasses. -
Dynamic property writes go through
invokedynamicby default, bringing property writes in line with property reads. -
Allocation-free higher-order methods:
java.util.functionoverloads across the GDK (each,collect,findAll,inject, …) take a lambda or method reference directly, with no per-callClosureallocation. -
Compound-assignment operator overloading (
plusAssign,minusAssign, …) letsa += bmutate in place rather than allocate a new object each time. -
Reflection-free MOP dispatch: once a method has been called often enough,
CachedMethod.invokeinstalls a generated hidden nestmate thatINVOKE`s the target directly instead of going through `Method.invoke, so the JIT sees a constant target where it previously saw a reflective call (GROOVY-12325). -
Faster closure calls from Java: the
Closure.calloverride cache is now an arity-indexed table, so multi-argument closure invocations from Java — allMapiteration,eachWithIndex,inject— take the cached fast path instead of full metaclass dispatch (GROOVY-12164, GROOVY-12165). -
Smaller bytecode: a new peephole optimizer rewrites short, local instruction sequences into cheaper equivalents, with no behavioural change.
-
Fewer closure classes (experimental, GEP-27): statically-compiled lambdas can be hoisted to a method on the enclosing class, and eligible closures packed, instead of generating a class per closure — cutting class count, jar size and allocation. Both are opt-in, and the value stays a real
groovy.lang.Closure. -
Nested
copyWithrebuilds only the branches that change, so untouched subtrees keep their identity through structural sharing. -
Parallel collections and the async/await support put idle cores and virtual threads to work on collection and I/O-bound workloads.
Groovy’s performance work is tracked continuously by a JMH benchmark suite published to a
public dashboard — including a Grails-oriented
metaclass-heavy suite that
watches the dispatch patterns dynamic frameworks depend on, and an
idiom-comparison suite that measures
time and allocation side by side. The latter makes the "fat-free" story concrete: counting
matches in a 1,000-element list runs around twelve times faster through the new
java.util.function overloads than through the rcurry closure idiom, and allocates
nothing at all where the closure forms allocate tens of kilobytes per call.


Read those two charts together with one caveat: the rcurry and closure-literal variants
are @CompileDynamic, since that is how the idiom is normally written, while the overload
variants are @CompileStatic. The dividing line is therefore the compilation mode as much
as the closure — as the last @CompileStatic row shows, a plain Closure routed through
Closures.curryWith is just as allocation-free, and the quickest variant measured.
A few other measured figures, each with the workload it came from:
| What was measured | Result |
|---|---|
|
|
A representative Grails |
24 closure classes totalling 80 KB — 1.19x the bytecode of the entire rest of the class |
A class reproducing seven real Grails |
8 class files → 1; 23.8 KB → 7.3 KB of bytecode; ~90% of the bytes removed
were |
Groovy’s own |
148 → 44 classes, 520 KB → 228 KB; 66/66 tests pass; cold suite ~6% faster |
Two honesty notes on those. Closure packing is off by default in Groovy 6, so its
rows describe a flag you switch on, not a dividend you get by upgrading; its own JMH
runs put capturing closures at 1.4—1.9x faster packed but the tightest non-capturing
collect shape at ~0.6x — that is, slower — with Grails-shaped blended workloads
netting +17—23%. And the closure-class figures are bytecode size and class count,
which is a cold-start and jar-size story: on that same groovy-macro module a
steady-state hot loop measured at parity (~0.98x), not faster.
The RemarkCriteria row deserves its own caveat, because it is the one figure above
that Groovy 6 does not bank. Those 24 closure classes come from static constraints,
mapping and namedQueries blocks, which are field-assigned — precisely the signal
that makes packing’s escape gate decline them, so they stay real closure classes and
their delegate resolution is untouched. Hoisting nested closures like these needs
owner-correct retargeting, which is Groovy 7 work. The number is there to show what
closure scaffolding costs in idiomatic Grails code, not what upgrading recovers.
The GDK standard library keeps growing, so more everyday tasks are one method call away rather than a hand-written loop or an extra dependency.
-
New methods including
groupByMany,waitForResult,findGroups/findAllGroups,isSorted,zipWithNext/groupConsecutive, and lazygrepping. -
Asynchronous file I/O on
Path(textAsync,bytesAsync,writeAsync) returningCompletableFuture— composes withawait. -
Streamlined process handling:
pipeline,onExit,toProcessBuilder, named-parameterexecute(dir:, env:, …).
Tooling Improvements
The docs, shell and console catch up with modern expectations — Markdown, syntax highlighting, dark mode and inline charts.
-
GroovyDoc adds JEP 467 Markdown doc comments (
///) and JEP 413{@snippet}blocks for inline and external code samples. Also added is Prism.js syntax highlighting, class-hierarchy tree pages,{@value}/{@inheritDoc}support, and improved script documentation. -
Light, dark, "follow system" and custom themes for GroovyDoc, the GDK reference, and GroovyConsole.
-
Groovysh gains an
/imgcommand for inline images and charts using JLine’s terminal-graphics support, Markdown handling in/slurp. -
groovyc --error-format shortemits onefile:line:column: severity: messageline per diagnostic — the shape editors, CI log parsers and coding agents already understand. -
Clearer syntax error messages for the mistakes people actually make: unclosed strings and comments reported where they opened, an illegal escape in a Windows path named as one, invisible pasted Unicode shown as
\u200brather than as nothing at all, missing punctuation named (Missing '('), and reserved words answered with the Groovy spelling ('const' is not supported; use 'val' or 'static final' instead).
JDK/Java Integration Improvements
Keeps Groovy a first-class citizen on today’s JVM: current JDK support, one-line module imports, and cleaner interop for mixed Groovy/Java projects.
-
Tested across JDK 17–27; JDK 17 is now the minimum supported runtime. Module import already mentioned.
-
Generic type syntax now follows the JLS: rare types, nested parameterizations,
instanceofreifiability and type-variable scoping all behave asjavacdoes, with matching diagnostics. -
Joint Compilation Stub Improvements: AST-transform-generated members (
@Immutable,@Builder,@TupleConstructor,@Delegate, …) are now visible in generated stubs. Java code in mixed-language projects can finally call constructors and methods contributed by transforms. -
GraalVM native images: an AOT link mode lets even dynamic Groovy dispatch run ahead-of-time compiled — including already-published indy-compiled jars, without recompilation.
-
Non-HotSpot runtimes: the runtime no longer assumes it is running on a HotSpot JVM built from a current JDK — version detection, accessibility rules, hidden classes,
ScopedValueand the indy exception handlers all degrade instead of failing, which is what it takes to come up on Android’s ART.
Security & Hardening
This is the safest Groovy yet — a broad sweep of security hardening across the core and modules, secure-by-default where it counts.
-
Secure-by-default XML parsing (XXE/DTD protections) across the XML APIs.
-
SQL-injection defence in depth: the
SqlInjectionCheckerat compile time plusgroovy-sqlrejection at runtime. -
Regex timeouts (
@SafeRegex) guard against ReDoS, alongside JSON nesting-depth limits and stricter Grape coordinate validation. -
Path containment and least privilege across the tooling: GroovyDoc writes only inside its destination directory and never follows a symbolic link out of
doc-files/,grape uninstalldeletes only inside the module cache,deleteDirtreats a Windows junction as a leaf instead of entering it, thegroovy.ast=xmldump inherits the source file’s permissions rather than the umask, and a failing groovlet no longer returns internals to the client. -
Fewer sandboxing gaps:
SecureASTCustomizerrestrictions now also cover constructors, initializer blocks, field initializers and authored code relocated into generated or synthetic members (purely generated code, such as accessors and trait bridges, remains exempt); embedders can disable@ASTTestwhen compiling source they don’t control.
New Modules

Groovy 6 ships eight new optional modules, plus two long-standing pieces of core (Grape’s Ivy backend and the classic call-site caching runtime) split out into their own modules:
| Module | Purpose |
|---|---|
|
Classic (non- |
|
Standalone Java library exposing the |
|
CSV parsing and writing via Jackson CSV |
|
|
|
New |
|
Imperative DSL and declarative annotation-driven client over JDK
|
|
CommonMark Markdown parser |
|
AwaitableAdapter SPI for
Project Reactor ( |
|
|
|
Groovy runner for JUnit 6 (Jupiter) tests as scripts |
Most of these modules have a dedicated section below covering their public API, examples, and any migration notes.
Native Async/Await (incubating)
Groovy 6 adds native async/await support
(GROOVY-9381),
enabling developers to write concurrent code in a sequential, readable style — no callbacks, no java.util.concurrent.CompletableFuture chains, no manual thread management.
On JDK 21+, tasks automatically leverage virtual threads.
See also the async/await blog post for a detailed walkthrough.

Before and after
Without async/await, concurrent code requires chaining futures:
// Before: CompletableFuture chains
def future = CompletableFuture.supplyAsync { loadUserProfile(id) }
.thenCompose { profile -> CompletableFuture.supplyAsync { loadQuests(profile) } }
.thenApply { quests -> quests.find { it.active } }
def quest = future.join()
With async/await, the same logic reads like synchronous code:
// After: sequential style, concurrent execution
def quest = await async {
def profile = await async { loadUserProfile(id) }
def quests = await async { loadQuests(profile) }
quests.find { it.active }
}
Exception handling works with standard try/catch — no .exceptionally() chains.
Parallel tasks and combinators
Launch tasks concurrently and coordinate results:
def a = async { fetchFromServiceA() }
def b = async { fetchFromServiceB() }
def c = async { fetchFromServiceC() }
// Wait for all three
def (resultA, resultB, resultC) = await(a, b, c)
Generators with yield return
An async closure containing yield return becomes a lazy generator — it produces values on demand with natural back-pressure:
def fibonacci = async {
long a = 0, b = 1
while (true) {
yield return a
(a, b) = [b, a + b]
}
}
assert fibonacci.take(8).collect() == [0, 1, 1, 2, 3, 5, 8, 13]
Channels
Go-style inter-task communication. A producer sends values into a channel (AsyncChannel); a consumer receives them:
def ch = AsyncChannel.create(5) // buffered channel
async {
for (i in 1..10) ch.send(i)
ch.close()
}
for (val in ch) { println val } // prints 1..10
Structured concurrency
AsyncScope binds the lifetime of child tasks to a scope — when the scope exits, all children are guaranteed complete or cancelled:
AsyncScope.run {
def users = async { loadUsers() }
def config = async { loadConfig() }
processResults(await(users), await(config))
}
// Both tasks guaranteed complete here
Iterating and cleaning up: for await and defer
for await consumes an async source — a generator, channel, or
reactive stream — pulling values as they arrive, while defer
registers a cleanup action that runs when the enclosing scope exits
(LIFO order, on success or failure alike):
async {
def reader = openLogStream()
defer { reader.close() } // runs on scope exit, always
for await (line in tailLines(reader)) { // consume values as produced
if (line.contains('ERROR')) println line
}
} // reader.close() runs here
defer is a contextual keyword: it is parsed as a keyword only inside
async closures, so existing code that uses defer as an ordinary
identifier elsewhere is unaffected
(GROOVY-12254).
Feature summary
| Feature | Description |
|---|---|
|
Start background tasks; collect results in sequential style |
Virtual threads |
Automatic on JDK 21+; cached thread pool fallback on JDK 17—20 |
Awaitable |
Wait for all tasks to complete |
|
Race — first to complete wins |
|
First success wins (ignores individual failures) |
|
Wait for all; inspect each outcome individually |
|
Lazy generators with back-pressure |
Buffered and unbuffered Go-style channels |
|
|
Iterate over async sources (generators, channels, reactive streams) |
|
LIFO cleanup actions, runs on scope exit regardless of success/failure |
|
Structured concurrency — child lifetime bounded by scope |
Timeouts |
|
|
Non-blocking pause |
|
|
Framework adapters (SPI) |
|
Executor configuration |
Pluggable; default auto-selects virtual threads or cached pool |
See the Async/Await user guide for the full API and additional examples.
Integrated Concurrency and Parallel Processing (incubating)
Groovy 6 brings a unified concurrency and parallel-processing toolkit
into core under
GEP-18: Integrated Concurrency and Parallel Processing
(GROOVY-11952,
GROOVY-11953).
The new abstractions live in the groovy.concurrent package and
modernise patterns from
GPars around virtual threads, structured concurrency,
and Groovy 6’s async/await. The same combinators
(await, Awaitable.all, for await) work uniformly across all of
them — agents, actors, dataflow variables, channels, and parallel
collections — so you compose features rather than learning separate
APIs.
For Java-only consumers, the same APIs are also published as the
new groovy-concurrent-java module — see Java-only module: groovy-concurrent-java.
Agents — thread-safe mutable state
An Agent wraps a value and serialises
updates via functions, eliminating data races by design. Reads compose
with await:
import groovy.concurrent.Agent
def counter = Agent.create(0)
counter.send { it + 1 }
counter.send { it + 1 }
counter.send { it + 1 }
assert await(counter.getAsync()) == 3
An agent also exposes its update stream as a
java.util.concurrent.Flow.Publisher via changes(), so for await
consumes state transitions directly:
def agent = Agent.create(0)
async {
3.times { agent.send { it + 1 } }
agent.shutdown()
}
def seen = []
for await (v in agent.changes()) { seen << v }
assert seen == [1, 2, 3]
@ActiveObject — actor semantics with class syntax
A hand-written actor (Actor) encodes its
message protocol explicitly — typed messages, a switch over message
kinds, and send/sendAndGet plumbing at every call site — so a
reader (human or AI) has to trace each message kind through the
dispatch loop and every call site to know the actor is used safely.
@ActiveObject (target: TYPE)
inverts this: write a normal class, mark the methods that run on the
actor’s serialised mailbox with
@ActiveMethod (target: METHOD),
and the AST transform routes those calls through an internal actor:
import groovy.transform.ActiveObject
import groovy.transform.ActiveMethod
@ActiveObject
class Account {
private double balance = 0
@ActiveMethod
void deposit(double amount) { balance += amount }
@ActiveMethod
void withdraw(double amount) {
if (amount > balance) throw new RuntimeException('Insufficient funds')
balance -= amount
}
@ActiveMethod
double getBalance() { balance }
}
def account = new Account()
account.deposit(100)
account.deposit(50)
account.withdraw(30)
assert account.getBalance() == 120.0
The thread-safety contract is now explicit and local. The
@ActiveObject annotation declares the concurrency model;
@ActiveMethod bodies contain plain business logic; callers see
ordinary method calls — no message types to invent, no switch to
parse, no manual reply plumbing. For non-blocking use,
@ActiveMethod(blocking = false) returns an Awaitable that plugs
straight into await and Awaitable.all.
Dataflow variables
A DataflowVariable is a
single-assignment variable: any thread that reads before it is bound
blocks until a value is available. DataflowVariable implements
Awaitable, so it composes directly with await and async {}
regardless of which task binds first:
import groovy.concurrent.DataflowVariable
def x = new DataflowVariable()
def y = new DataflowVariable()
def z = new DataflowVariable()
async { z << await(x) + await(y) } // blocks until x and y bind
async { x << 10 } // bind in any order
async { y << 5 }
assert await(z) == 15
The companion Dataflows class
auto-creates variables on property access for an even more concise
form (async { df.fullName = "${df.first} ${df.last}" }).
Parallel collections
For CPU-bound data parallelism, Collection gains a family of
parallel methods backed by a ForkJoinPool:
def squares = (1..1_000).toList().collectParallel { it * it }
def adults = people.findAllParallel { it.age >= 18 }
def total = amounts.sumParallel { a, b -> a + b }
ParallelScope.withPool
binds a pool for a block, and Pool
offers Pool.cpu(), Pool.fixed(n), Pool.io(), and
Pool.virtual() factories so CPU-bound and I/O-bound workloads can
use distinct pools without leaking into the common pool. The
previously introduced @Parallel loop
annotation shares this infrastructure:
import groovy.concurrent.ParallelScope
import groovy.concurrent.Pool
ParallelScope.withPool(Pool.cpu()) { scope ->
def hot = bigList.findAllParallel { it.score > threshold }
hot.eachParallel { archive(it) }
}
As a rule of thumb, prefer parallel collections (or @Parallel) for
CPU-bound work and async/await with virtual threads for
I/O-bound work — ForkJoinPool workers are precious and should not
be tied up in Thread.sleep, network calls, or blocking I/O.
Channel composition and broadcast
AsyncChannel (introduced with async/await) gains composable
pipeline operations — filter, map, merge, split, tap — and
ChannelSelect for Go-style
multi-channel selection.
BroadcastChannel adds
one-to-many delivery and exposes asPublisher() for direct
Flow.Publisher interop, so any subscriber — including a for await
loop — receives every message:
import groovy.concurrent.BroadcastChannel
def broadcast = BroadcastChannel.create()
def publisher = broadcast.asPublisher()
async {
['hello', 'world'].each { broadcast.send(it) }
broadcast.close()
}
for await (msg in publisher) { println msg } // hello, world
The same for await works against any JDK Flow.Publisher, Reactor
Flux (via groovy-reactor), or RxJava Observable (via
groovy-rxjava).
Choosing among channels with ChannelSelect
ChannelSelect.from(a, b, …) waits for the first available value
from several channels, as Go’s select does. In Groovy 6 it grew from
that starting point into the full choice construct of the CSP
literature, and the underlying protocol changed to make that sound: a
select now works by claim — the branches share one flag that a
channel tests under its own lock immediately before handing a value
over, so exactly one branch dequeues and the losing branches never
touch their channels
(GROOVY-12320).

Four capabilities build on that:
Choice policy. A ready channel completes during registration, so
select() takes the channel listed first — a priority select, under
which a channel that always has a value waiting starves those after
it. fair() rotates the registration order so every ready channel is
taken within n calls (JCSP’s fairSelect), and random() shuffles
it so every ready channel is equally likely (Go’s policy). Keep the
instance across calls for fair() to rotate.
Mixed offers. A branch may offer to send as well as receive — "I will send my opener, or take my peer’s if it sends first" (GROOVY-12323):
import static groovy.concurrent.ChannelSelect.*
def result = await offers(send(ping, 1), receive(pong)).select()
if (result.send) {
// my opener committed: continue the ping branch
} else {
// my peer opened first: result.value is the pong opener
}
Exactly one offer commits; a committed send behaves exactly like
ping.send(1), while a retired one leaves no trace — no buffered
residue, no waiting sender.
Preconditions. when writes a guard onto the offer itself, as
occam, Ada, Erlang and Kotlin do — the guarded choice of CSP
(GROOVY-12326):
def sel = offers(receive(input).when { held.size() < capacity }, // (1)
receive(request).when { !held.isEmpty() }) // (2)
def result = await sel.select()
-
take input only while there is room
-
answer a request only while there is something to hand over
A guarded-off offer is not registered on its channel but keeps its
position, so result.index denotes the same branch either way — dropping the offer from the argument list instead would silently
renumber every branch after it. The guard is a closure, consulted
afresh on each select() call, so one select can be built once and
reused as the state it guards on changes. The same preconditions can
be passed positionally, one flag per offer, which suits a mask
computed as a whole
(GROOVY-12324);
a flag conjoins with whatever guard its offer carries.
Timeouts as a branch. A deadline can be a branch of the select
rather than an exception thrown around it by orTimeout
(GROOVY-12343).
The timer offer after re-arms on every round; the timer channel
AsyncChannel.after is a fixed deadline shared by every round that
selects on it:
def sel = offers(receive(work), after(100)).fair()
while (true) {
def result = await sel.select()
if (result.timeout) {
// nothing arrived within 100 ms of this call: result.value is the Instant
} else {
handle(result.value)
}
}
A timer offer that loses its round is cancelled with the other losing
branches, so it leaves nothing armed. Listed last, after(0) has
already elapsed and so is ready at registration — making it the
default clause of Go’s select: take a transfer if one can complete
at once, otherwise proceed without waiting.
See the Async/Await user guide for the
full select API, including result.channel for recognising a branch
by identity rather than position.
Java-only module: groovy-concurrent-java
For Java, Kotlin, and other JVM languages that want the same
toolkit without the full Groovy runtime, the standalone
groovy-concurrent-java module exposes AsyncScope, Pool,
ParallelScope, Actor, Agent, DataflowVariable,
AsyncChannel, BroadcastChannel, and ChannelSelect against
plain java.util.function types. The Groovy-only sugar
(async/await keywords, for await, defer, @ActiveObject,
Dataflows, parallel extension methods) requires the full Groovy
dependency.
import groovy.concurrent.AsyncScope;
import org.apache.groovy.runtime.async.AsyncSupport;
var result = AsyncScope.withScope(scope -> {
var a = scope.async(() -> fetchUser(id));
var b = scope.async(() -> fetchOrders(id));
return Map.of(
"user", AsyncSupport.await(a),
"orders", AsyncSupport.await(b)
);
});
The module ships under the org.apache.groovy:groovy-concurrent-java
coordinate and is mutually exclusive with the full groovy runtime
(Gradle enforces this via a shared capability; a runtime warning
fires if both jars are detected on the classpath).
Feature summary
| Feature | Description | Ticket |
|---|---|---|
|
Thread-safe mutable value updated via serialised functions; exposes
a |
|
|
Stateless and stateful actors with |
|
|
AST-driven actor semantics with class syntax. |
|
|
Single-assignment variables; |
|
|
Composable channel pipelines. |
|
|
One-to-many broadcast (with |
|
|
A select takes a value from exactly one channel and leaves the losing
branches untouched, rather than consuming and re-sending. |
|
|
|
|
|
|
|
Timer branches |
|
|
Parallel collection methods |
|
|
|
|
|
|
Standalone Java module exposing the same concurrent APIs without the Groovy runtime. |
|
|
An |
See the user guides for the full APIs and additional examples: concurrent actors and agents, concurrent dataflow, parallel collections, and the Java-only module.
HttpBuilder: HTTP Client Module (incubating)
Groovy 6 introduces a new groovy-http-builder module
(GROOVY-11879,
GROOVY-11924)
providing both an imperative DSL and a declarative annotation-driven
client over the JDK’s java.net.http.HttpClient.
It is designed for scripting, automation, and typed API clients,
filling the gap left by the earlier HttpBuilder/HttpBuilder-NG libraries.
The two entry points are HttpBuilder
for the imperative DSL and
@HttpBuilderClient for the
declarative client; supporting types live in the groovy.http package.
Applicable targets for the new annotations: @HttpBuilderClient — TYPE;
@Get/@Post/@Put/@Delete/@Patch/@Form/@Timeout — METHOD;
@Body/@BodyText/@Query — PARAMETER;
@Header — TYPE, METHOD.
Imperative DSL
A closure-based DSL for quick scripting:
import static groovy.http.HttpBuilder.http
def client = http('https://api.github.com')
def result = client.get('/repos/apache/groovy')
assert result.json.license.name == 'Apache License 2.0'
Responses auto-parse by content type: result.json, result.xml,
result.html (via jsoup), or result.parsed for auto-dispatch.
Declarative client
Define a typed interface and Groovy generates the implementation at compile time. Parameters are mapped by convention — no annotations needed for the common case:
@HttpBuilderClient('https://api.example.com')
interface UserApi {
@Get('/users/{id}')
User getUser(String id) // path param: {id}
@Get('/users')
List<User> search(String name) // implied query param: ?name=...
@Post('/users')
User create(@Body Map user) // JSON body
@Post('/login')
@Form
Map login(String username, String password) // form-encoded
}
def api = UserApi.create()
def user = api.getUser('42')
Async support
Both sides offer native async via HttpClient.sendAsync() — no
extra threads consumed while waiting:
// Imperative
def future = client.getAsync('/slow-endpoint')
def result = future.get()
// Declarative
@HttpBuilderClient('https://api.example.com')
interface AsyncApi {
@Get('/data/{id}')
CompletableFuture<Map> getData(String id)
}
These CompletableFuture returns are first-class await targets in
Groovy 6: def data = await api.getDataAsync('42').
Feature summary
| Feature | Imperative | Declarative |
|---|---|---|
HTTP methods (GET, POST, PUT, DELETE, PATCH) |
All |
|
JSON body / response |
|
@Body / return-type driven |
Form-encoded body |
|
|
Plain text body |
|
|
XML / HTML response |
|
|
Typed response objects |
Manual ( |
Automatic (return type driven) |
Query parameters |
|
Implied from parameter name (or @Query) |
Path parameters |
Manual |
Auto-mapped via |
Headers |
|
@Header on interface/method |
Async |
|
|
Timeouts (connect / request) |
Config DSL |
|
Per-method timeout |
Per-request |
@Timeout |
Redirect following |
Config DSL |
|
Error handling |
Manual (check |
Auto-throw; custom exception via |
JDK client access (auth, SSL, proxy) |
|
|
For safety, requests — and any redirects they follow — are confined to
the configured base URI, so a redirect cannot silently escape to a
different host
(GROOVY-12182).
Where redirects are followed without confinement, HttpBuilder follows
them itself and drops the caller’s configured headers for good once a hop
leaves the request’s original origin, so an Authorization header, cookie
or API key configured on the builder is never sent to whatever origin a
Location names. Delegating this to the JDK client was not sufficient:
whether the well-known credential headers are stripped varies by JDK
update level, and a header the platform does not recognise is forwarded on
every JDK
(GROOVY-12274).
See the HTTP client user guide for the full API and configuration options.
AST Transforms in More Places (incubating)
Groovy 6 extends the AST transformation infrastructure to support
annotations on loop statements — for-in, classic for, while, and do-while
(GROOVY-11878).
The following built-in transforms now declare LOOP as a valid target:
-
@Parallel — runs each iteration on its own task; uses virtual threads on JDK 21+ and falls back to platform threads otherwise (applicable target:
LOOPonly) -
@Invariant — asserts a condition at the start of each iteration (also valid on imports; see Groovy-Contracts Enhancements)
-
@Decreases — loop termination measure; see Groovy-Contracts Enhancements
-
@ASTTest — compile-time AST inspection utility used primarily for transform development and testing
@Parallel is the most visible application:
@Parallel
for (int i in 1..4) {
println i ** 2
}
// Output (non-deterministic order): 1, 16, 9, 4
Custom transforms can opt into loop targeting by declaring
@ExtendedTarget(ExtendedElementType.LOOP), following the same
contract as class/method/field-level transforms. See
Improved Annotation Validation for the corresponding compile-time
validation rules.
Fluent AST Query API (incubating)
A small, read-only, fluent query API over the Groovy AST is now available
in the org.codehaus.groovy.ast.query package (AstQuery / AstContext).
It lets AST transform, type-checker and tooling authors locate nodes
declaratively, without hand-writing a CodeVisitorSupport subclass that
carries mutable state
(GROOVY-12116).
Several internal AST walkers were ported to it. For example, the
@TailRecursive recursive-call detector dropped from a 41-line
CodeVisitorSupport subclass — two visitXxx overrides plus mutable
fields and a synchronized entry point — to a 16-line class whose
entire logic is a single query:
public boolean test(MethodNode method) {
RecursivenessTester tester = new RecursivenessTester();
return AstQuery.from(method.getCode())
.descendants(MethodCallExpression.class, StaticMethodCallExpression.class)
.where(call -> tester.isRecursive(Maps.of("method", method, "call", call)))
.any();
}
Groovy-Contracts Enhancements
The groovy-contracts module receives several enhancements in Groovy 6,
including support for contracts in scripts, loop annotations,
and frame conditions. The module itself graduates from incubating to
stable in 6.0 (see Stabilised features); the newest of these enhancements — loop-level annotations and @Modifies frame conditions, which build on
the still-incubating loop AST-transform support — remain incubating for
this release.
Contracts in scripts
Contract annotations now work in Groovy scripts, not just inside classes (GROOVY-11885). @Requires and @Ensures can be placed on script methods, and @Invariant can be placed on an import statement to apply as a class-level invariant for the script:
@Invariant({ balance >= 0 })
import groovy.transform.Field
import groovy.contracts.Invariant
@Field Integer balance = 5
@Requires({ balance >= amount })
def withdraw(int amount) { balance -= amount }
def deposit(int amount) { balance += amount }
deposit(5) // balance = 10, OK
withdraw(20) // throws ClassInvariantViolation (balance would be -5)
Combining contracts
These annotations can be combined to build strong confidence in the correctness of an algorithm. Consider this insertion sort that merges two pre-sorted lists:
@Ensures({ result.isSorted() })
List insertionSort(List in1, List in2) {
var out = []
var count = in1.size() + in2.size()
@Invariant({ in1.size() + in2.size() + out.size() == count })
@Decreases({ [in1.size(), in2.size()] })
while (in1 || in2) {
if (!in1) return out + in2
if (!in2) return out + in1
out += (in1[0] < in2[0]) ? in1.pop() : in2.pop()
}
out
}
The @Ensures postcondition verifies that the result is sorted.
The @Invariant asserts that no elements are lost or gained — the total number of elements across all three lists stays constant
throughout the loop. The @Decreases annotation uses a lexicographic
measure over the two input list sizes, giving us confidence that
the loop terminates: on each iteration at least one input list
shrinks, and they can never grow.
See also the loop invariants blog post for more on how contracts support correctness reasoning.
Exceptional behaviour with @ThrowsIf
The @Requires/@Ensures/@Invariant trio covers normal behaviour.
The new @ThrowsIf annotation
(GROOVY-12135)
covers exceptional behaviour — the guard clause that is otherwise
written by hand:
@ThrowsIf(value = { b == 0 }, exception = ArithmeticException)
int divide(int a, int b) { a.intdiv(b) }
It reads as an iff: the method throws ArithmeticException exactly
when b == 0. With the default woven = true, groovy-contracts
generates the guard at method entry (the same idea as
@NullCheck for the null-check special
case); woven = false keeps it as machine-readable documentation only.
Feature summary
| Feature | Description | Ticket |
|---|---|---|
|
Assert a condition at the start of each iteration of any loop type
(for-in, classic for, while, do-while). Multiple invariants can be stacked.
Violations throw |
|
Loop termination measure (loop variant). Takes a closure returning a
value or list of values that must strictly decrease each iteration while
remaining non-negative. Lists use lexicographic comparison.
Violations throw |
||
Frame condition declaring which fields a method may change.
Everything not listed is guaranteed unchanged.
@Pure is shorthand for |
||
Contracts in scripts |
|
|
|
Postconditions are now supported on |
|
Guard-clause (exceptional-behaviour) contract: throw a given exception
exactly when a condition holds. With |
||
|
Beyond loop variants, |
|
|
A precondition can opt out of weaving to act as machine-readable documentation only — useful when an existing library already performs the validation and you want that intent clearer than javadoc alone. |
|
Multiple |
All postconditions on a method now fire; previously only the first
|
|
Nested closures in conditions |
Contract conditions may now contain nested closures (e.g.
|
|
Loop annotations under |
|
|
|
A |
Monadic Comprehensions (incubating)
Groovy 6 introduces DO — a comprehension macro that gives Scala
for-comprehension and Haskell do-notation ergonomics to any type
with monadic shape. See GEP-23: Monadic
comprehensions
(GROOVY-12021) for
the full specification.
A DO block rewrites at compile time to a chain of bind operations on
a participating carrier type. The notation reads top-to-bottom;
short-circuiting (empty Optional, failed Try, failed Awaitable)
is delivered by the carrier rather than by the macro.
import static org.apache.groovy.macrolib.MacroLibGroovyMethods.DO
@TypeChecked(extensions = 'groovy.typecheckers.MonadicChecker')
Optional<String> greet(Map<String, String> users, String userId) {
DO(user in Optional.ofNullable(users[userId]),
name in Optional.ofNullable(user.split(/\|/)[0])) {
Optional.of("Hello, $name!".toString())
}
}
assert greet([u42: 'Alice|admin'], 'u42').get() == 'Hello, Alice!'
assert greet([u42: 'Alice|admin'], 'u99') == Optional.empty()
The same shape works over Stream, CompletableFuture,
Awaitable and
DataflowVariable, and over
FunctionalJava and Vavr control carriers (recognised by name, no
Groovy dependency on the library).
Carrier participation
A type qualifies as a carrier via any of four routes. The built-in
allow-list recognises stdlib and Groovy-core types by class — Optional, Stream, CompletionStage/CompletableFuture, and
Awaitable/DataflowVariable — and common FunctionalJava
(fj.data.) and Vavr (io.vavr.control.) carriers by
fully-qualified name, with no Groovy dependency on those libraries.
Beyond the list, any type exposing a single-argument flatMap (plus
map) qualifies structurally, and a user type whose method names
diverge can opt in with @Monadic
(naming its bind/map, and optionally a unit factory for
law-checking tooling). GEP-23 has the per-carrier method mapping and
the exact shape rules.
Compile-time checking
Two type-checking extensions back the feature, each activated like any
other (@TypeChecked(extensions = '…')).
MonadicChecker — shown in the
example above — enforces carrier participation and the closure-return
shape of DO chains, and restores the comprehension’s static result
type. MonadicShapeChecker
is independent of DO: it lints hand-written
flatMap/map/thenCompose/thenApply chains, catching a flatMap
that returns a non-carrier or a different carrier (which Groovy’s
closure coercion would otherwise let through), and the
map-returning-M<M<T>> foot-gun. The two compose — code mixing both
forms can opt into both.
Feature summary
| Feature | Description | Ticket |
|---|---|---|
|
Compile-time rewrite of generator/body comprehensions into a chain
of bind operations on a participating carrier. Recognises stdlib and
Groovy-core carriers by type; FunctionalJava and Vavr control carriers
by fully-qualified name; structural and |
|
Opt-in marker for user types whose bind/map methods diverge from the
structural convention. Optional |
||
Type-checking extension that enforces carrier participation, the
closure-return shape of |
||
Sibling extension that lints hand-written
|
||
Carrier registry |
Standard allow-list shared by the runtime bind/map dispatcher and the type-checking extensions; covers stdlib carriers by type and FunctionalJava / Vavr carriers by name. |
See GEP-23 for the full specification, the
desugaring rules, the carrier-shape requirements, and the relationship
to for await / parallel collections / actors and agents.
Trait Static Members (incubating)
Traits may declare static methods, properties and fields, with each
implementing class receiving its own copy of the static state.
This feature has been available across numerous Groovy versions
but is further enhanced and clarified in Groovy 6
(the area remains incubating). A summary follows, but
see GEP-22 for the full trait specification.
A public trait static method is declarer-bound by default: a call
to it from trait code always reaches the trait’s own copy, and it is
promoted onto the generated trait interface as a JVM-native interface
static, so both Trait.m() and Impl.m() resolve at the JVM level
(GROOVY-12111).
When a trait wants implementing classes to be able to override a
static default — and wants the trait’s own logic to honour that
override — the method can be annotated @groovy.transform.Virtual.
Trait-body calls to a @Virtual static then dispatch through the
implementing class, so a same-signature static on the implementer
shadows the trait’s default. This is the template-method pattern applied
to a static member, and the mechanism behind framework hooks such as
Grails' Validateable.defaultNullable()
(GROOVY-12093).
import groovy.transform.Virtual
trait Validateable {
@Virtual static boolean defaultNullable() { false } // overridable default
static boolean check() { defaultNullable() } // honours the override
}
class Book implements Validateable {
static boolean defaultNullable() { true } // per-class override
}
assert Book.check() // true
@Virtual is valid only on public, non-abstract static trait methods;
applying it elsewhere is a compile-time error. A @Virtual static is
not promoted onto the interface (interface statics are declarer-bound by
JVM rule), so a qualified Trait.m(…) to a @Virtual static is now
rejected with a clear compile-time error rather than a generic
"cannot find matching method"
(GROOVY-12112).
Static methods inherited from a super-trait now resolve from a sub-trait’s own body by the same rules as a static declared in the sub-trait, with subtype-aware argument resolution matching plain-class static inheritance (GROOVY-12106); this resolution is also independent of the order in which co-compiled sibling traits are processed (GROOVY-12117).
Two trait-body qualifier forms that previously produced invalid bytecode
or runtime errors are now rejected at compile time: T.this.*, which
has no coherent meaning inside trait code (use this.m(…) or
T.super.m(…))
(GROOVY-12104),
and an unqualified super.m(…) from a static trait method (use
T.super.m(…))
(GROOVY-12105).
See GEP-22 for the full trait specification, including the complete static-member dispatch model.
Type Checking Extensions

Groovy’s type checking is extensible, allowing you to strengthen
type checking beyond what the standard checker provides.
Groovy 6 adds support for parameterized type checking extensions
(GROOVY-11908),
allowing extensions to accept configuration arguments
directly in the @TypeChecked
annotation string.
Several new type checking extensions take advantage of this capability.
The new checkers below all live in the groovy.typecheckers package.
NullChecker
The NullChecker extension
(GROOVY-11894)
validates code annotated with @Nullable, @NonNull, and
@MonotonicNonNull annotations, detecting null-related errors
at compile time. It recognises these annotations by simple name
from any package (JSpecify, JSR-305, JetBrains, SpotBugs,
Checker Framework, or your own):
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker')
int safeLength(@Nullable String text) {
if (text != null) {
return text.length() // ok: null guard
}
return -1
}
assert safeLength('hello') == 5
assert safeLength(null) == -1
Without the null guard, dereferencing a @Nullable parameter
produces a compile-time error.
The checker also recognises safe navigation, early-exit patterns,
@NullCheck, and @MonotonicNonNull for lazy initialisation.
The guard vocabulary covers idiomatic Groovy: Groovy-truth guards
(if (x) { x.foo() }), instanceof checks, short-circuit
conjunctions (x != null && x.foo()), Objects.nonNull/isNull,
assert statements, and guard conditions in while loops and
ternaries are all recognised
(GROOVY-12208).
Nullness is also tracked through expression results, not just
variables: the result of a safe navigation used as an unguarded
receiver (a?.b.c), a @Nullable-returning call passed straight to a
@NonNull parameter, and ternary or Elvis expressions with a nullable
branch are all flagged
(GROOVY-12209).
And nullability metadata is honoured wherever it comes from:
type-use annotations and package-level JSpecify-style @NullMarked
declarations are read from compiled dependencies on the classpath
(including their package-info.class), so precompiled APIs are
checked just like source
(GROOVY-12206,
GROOVY-12207).
Calls that guarantee an argument is non-null when they complete
normally narrow that argument afterwards: validator methods such as
Objects.requireNonNull(x) (the natural "cast to non-null" escape
hatch) and Guava-style checkNotNull(x); test assertions such as
assertNotNull(x), with JUnit 4’s (message, actual) and
JUnit 5 / TestNG’s (actual, message) argument orders both recognised
by parameter types rather than position; and fluent assertion chains
like assertThat(x).isNotNull() (AssertJ, Truth, or similar), looking
through intermediate chained calls such as describedAs(…).
Consistent with the checker’s design, these methods are matched by
simple name, so any library following the common naming conventions is
recognised without configuration
(GROOVY-12250).
Nullness annotations are also honoured inside type arguments — JSpecify’s headline generics capability. Wherever the static type
checker’s generics inference carries a @Nullable type argument
through a class or method level type variable, the result is treated
as nullable: get(0) on a List<@Nullable String>, the idiomatic
subscript xs[0], GDK calls such as head() and first(), and
get(key) or m[key] on a Map<String, @Nullable Integer>. The
nullable result participates in all the existing analyses — unguarded
dereference is flagged, the recognised guards and safe navigation are
accepted, and passing it to a @NonNull parameter is an error — whether the annotated type appears in source or is read from a
compiled dependency
(GROOVY-12252).
Strict mode: no annotations needed
Passing strict: true extends the checker with flow-sensitive analysis
that detects null issues even in completely unannotated code — no @Nullable, no @NonNull, no special types:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker(strict: true)')
static main(args) {
def x = null
x.toString() // compile error: 'x' may be null
def y = null
y = 'hello'
assert y.toString() == 'hello' // ok: reassigned non-null
}
The checker tracks nullability through assignments and control flow,
catching potential dereferences that would otherwise surface only at runtime.
This is also an example of parameterized type checking extensions in action — the strict: true argument is passed directly in the extension string.
Strict mode also checks that every explicitly-annotated @NonNull
instance field is definitely initialized — at its declaration, in
an instance initializer block, or by every declared constructor (a
constructor delegating via this(…) relies on its delegate). A
@NonNull field nothing ever assigns is otherwise exactly the field
the checker would wrongly trust at every dereference:
@TypeChecked(extensions = 'groovy.typecheckers.NullChecker(strict: true)')
class Library {
@NonNull String catalog // never assigned
Library(String catalog) { } // forgot: this.catalog = catalog
}
reports @NonNull field 'catalog' is not initialized by all
constructors. Fields that are non-null merely by default (under
@NullMarked and friends) are not subject to the check, keeping it
noise-free for idiomatic Groovy such as named-argument construction
(GROOVY-12251).
See also the NullChecker blog post for a detailed walkthrough.
CombinerChecker
The parallel Collection reductions (sumParallel, injectParallel) split,
reorder, and recombine their input. That is only correct when the combining
function is associative — combine(a, combine(b, c)) must equal
combine(combine(a, b), c) — and, for the seeded injectParallel, when the
seed is an identity. A non-associative combiner (subtraction, division)
compiles and runs but produces a different, non-deterministic result depending
on how the work was partitioned — among the hardest bug classes to detect by
testing alone.
The CombinerChecker extension
(GROOVY-12013)
verifies at compile time that combiners reaching these methods carry the
contract. A combiner declares it with the new
@Associative and
@Reducer annotations (@Reducer also names an
identity element via zero()):
import groovy.transform.Associative
class Maths {
@Associative static int add(int a, int b) { a + b }
}
@TypeChecked(extensions = 'groovy.typecheckers.CombinerChecker')
def reduce() {
[1, 2, 3].injectParallel(0, Maths.&add) // ok: declared @Associative
[1, 2, 3].injectParallel(0) { a, b -> a + b } // ok: lenient, associative
[1, 2, 3].injectParallel(0) { a, b -> a - b } // compile error: non-associative
}
The default (lenient) mode flags only high-confidence problems — a
non-associative operator (-, /, %, **) applied directly to the
combiner parameters, or an injectParallel seed that contradicts a
@Reducer’s declared `zero(). The parameterized strict mode additionally
requires the combiner to be a method reference to an annotated method. The
checker also recognises Monoid/Semigroup carriers from external libraries,
and covers java.util.stream reduce overloads. Since associativity is
undecidable in general, this is a conservative, false-positive-averse safety
net rather than a proof: the annotations assert the law and are meant to be
backed by tests — and because the law is fully specified by the annotation,
those tests are machine-actionable and can be
auto-derived from the declaration.
SqlInjectionChecker
The SqlInjectionChecker extension
(GROOVY-12187)
flags a common SQL-injection smell at compile time: a GString whose
interpolated value is placed inside string quotes in a SQL query. Such a
value is concatenated into the query text rather than bound as a
PreparedStatement parameter, so quoting it defeats `groovy-sql’s
placeholder handling (CWE-89):
@TypeChecked(extensions = 'groovy.typecheckers.SqlInjectionChecker')
void findUser(Sql sql, String name) {
sql.rows("SELECT * FROM users WHERE name = '$name'") // flagged
sql.rows("SELECT * FROM users WHERE name = $name") // OK -- bound as a parameter
}
Like the other bundled checkers it lives in the groovy-typecheckers
module and is activated via @TypeChecked(extensions = …) or a global
ASTTransformationCustomizer. It complements the runtime hardening in
groovy-sql, which rejects the same quoted-interpolation pattern when a
query executes (see Breaking changes and
GROOVY-12118).
Feature summary
| Feature | Description | Ticket |
|---|---|---|
Parameterized extensions |
Type checking extensions can accept configuration arguments directly
in the |
|
|
Compile-time null safety. Validates |
|
|
Flow-sensitive null analysis without annotations. Tracks variables
assigned |
|
|
Recognises null-safety facts from |
|
|
Broadened guard recognition (Groovy-truth, |
|
|
Type-use nullability annotations and package-level |
|
|
|
|
|
Every explicitly-annotated |
|
|
|
|
Verifies |
||
Enforces functional purity at compile time. Verifies |
||
Verifies combiners passed to |
||
Flags a |
Compiler-Supplied Class Tokens (incubating)
Erasure forces JVM APIs that need the runtime class of a generic type
argument to take an explicit Class token, even though that token
repeats a type the compiler already knows. The new
@ClassTag parameter annotation
(GROOVY-12115) marks
such a token as compiler-supplied: under @TypeChecked or
@CompileStatic, a call that omits the token selects the tagged
overload and the type checker injects X.class, reified from the
receiver’s type argument:
List<String> names = []
List<String> checked = names.asChecked() // compiler injects String.class
Injection is normally additive — it only applies when the call as
written matches no method, so existing code keeps its meaning. An API
author may additionally mark an overload @ClassTag(preempt=true),
letting it transparently upgrade calls that bound a token-less overload
declared by the same class. Groovy’s checked withDefault variants
declare this, closing the type-soundness hole of their lenient sibling
(GROOVY-11807): a
statically-compiled map.withDefault{ … } on a typed map now returns
a view that rejects ill-typed keys and values. Preemption is contained — a library can only upgrade calls to its own API, so a jar on the
compile classpath can never capture calls it does not own — and the
consuming build can veto it globally with
-Dgroovy.classtag.preemption.disable=true (or
CompilerConfiguration.setClassTagPreemptionDisabled). Dynamic code is
unaffected: there, the Class token is passed explicitly.
Regex Timeout Facility
Catastrophic backtracking can make an otherwise innocuous regular
expression run for a very long time on hostile input (ReDoS). The new
@SafeRegex transform
(GROOVY-12122) bounds
regex evaluation with a wall-clock timeout, throwing
groovy.util.regex.RegexTimeoutException when a match exceeds it:
@SafeRegex(millis = 250)
def match(String input) {
input ==~ /(a+)+$/ // guarded -- throws RegexTimeoutException if it runs too long
}
millis defaults to 1000. Matching semantics are otherwise unchanged.
When applied at class level, the guard reaches regexes in nested and
anonymous inner classes as well as the enclosing class’s own members
(GROOVY-12351).
Designed for Human and AI Reasoning
A key design goal for Groovy 6 is making code easier to reason about — for both humans and AI. Several of the contract and type checking features described above work together to achieve this: @Modifies and @Pure declare what changes (and what doesn’t), @Requires and @Ensures declare what holds before and after, and type checking extensions like ModifiesChecker and PurityChecker verify these declarations at compile time. The combined effect is that each method becomes a self-contained specification — you can reason about what it does without reading its body.
Consider this annotated class, verified by both ModifiesChecker
and PurityChecker:
@TypeChecked(extensions = ['groovy.typecheckers.ModifiesChecker',
'groovy.typecheckers.PurityChecker'])
@Invariant({ balance >= 0 })
class Account {
BigDecimal balance = 0
List<String> log = []
@Requires({ amount > 0 })
@Ensures({ balance == old.balance + amount })
@Modifies({ [this.balance, this.log] })
void deposit(BigDecimal amount) {
balance += amount
log.add("deposit $amount")
}
@Requires({ amount > 0 && amount <= balance })
@Ensures({ balance == old.balance - amount })
@Modifies({ [this.balance, this.log] })
void withdraw(BigDecimal amount) {
balance -= amount
log.add("withdraw $amount")
}
@Pure
BigDecimal available() { balance }
}
When analysing a sequence of calls:
account.deposit(100)
account.withdraw(30)
def bal = account.available()
With annotations, each call reads as a self-contained specification:
chain the @Ensures postconditions and trust @Modifies to bound what
changed, without opening a single method body.
Without annotations, the analyser must read every method body,
verify what each one modifies (2 fields × 3 calls = 6 "did this change?"
questions), re-verify earlier state after later calls, and check whether
available() has hidden side effects.
In general, this grows as O(fields × calls × call_depth) — which is where AI starts hallucinating or saying
"I’d need to see more context."
| What must be verified | With annotations | Without annotations |
|---|---|---|
Does |
No — |
Must read body + all callees |
Does |
No — |
Must read both method bodies |
Is |
Yes — |
Must read body, check for overrides |
What is |
Derive from |
Replay all mutations manually |
Can |
Check |
Must analyse all pairs for interference |
The type checkers provide the compile-time guarantee that these
annotations are truthful: ModifiesChecker verifies method bodies
only modify declared fields, and PurityChecker verifies @Pure
methods have no side effects. Without that guarantee, annotations
would be just comments — claims you’d still need to verify by
reading the code.
Reading and verification are two payoffs; a third is that the same
declarations are machine-actionable. Because @Pure, @Modifies,
@Requires/@Ensures, @Invariant/@Decreases and the algebraic
@Associative/@Reducer are both machine-readable and
compiler-enforced, tooling and AI skills can build on them rather than
guess — deriving tests and documentation, validating refactorings, and
treating each verified contract as a guardrail an agent must not cross.
The compile-time guarantee is what makes this safe: a skill can rely on
a @Pure method being pure because PurityChecker proved it, not
because a comment claimed it. A Groovy 6 codebase is therefore not only
easier for humans and AI to read — it is a verified specification
they can reliably build on.
val Keyword for Final Declarations
Groovy 6 adds val as a contextual keyword for declaring final
variables and fields
(GROOVY-9308,
GEP-16).
val complements the existing var keyword: val declares an
immutable binding (equivalent to final def), while var continues
to declare a mutable one. The shape and naming will be familiar to
anyone who has seen Kotlin or Scala code declaring read-only (immutable) variables.
val name = 'Groovy' // equivalent to: final def name = 'Groovy'
val list = [1, 2, 3] // shallow finality — list contents may still mutate
list << 4 // OK
name = 'Other' // compile error: cannot assign to final variable
Like var, val is contextual — it remains usable as a variable
name, method name, map key, or property name. Existing code such as
def val = 1, obj.val, and [val: 42] continues to work
unchanged. val is rejected only where it would be ambiguous with a
type (for example, as a method return type or in a class val {}
declaration), mirroring the restrictions already in place for var.
When statically type-checking code, val carries the same type
inference rules as var:
val x = 42 // inferred as int
val s = 'hello' // inferred as String
val list = [1, 2, 3] // inferred as ArrayList<Integer>
A small number of pre-existing parser edge cases around var apply
equally to val — chiefly a field named val (or var) declared
immediately before a method or constructor, and val as Type cast
expressions. For codebases that need more time to migrate, a
-Dgroovy.val.enabled=false system property disables the keyword
entirely, lexing val as a plain identifier
(GROOVY-11994).
See GEP-16 for the full specification, including the migration flag and the complete list of edge cases.
Multi-assignment Destructuring
Groovy 6 extends def (…) multi-assignment with rest bindings and
map-style key destructuring
(GROOVY-11964).
Three idioms common in modern languages but previously unavailable in
Groovy are now supported. The extension is strictly additive — every program valid in Groovy 4 / 5 compiles with identical semantics — because each new shape uses an unparseable token sequence in the
existing grammar.
See GEP-20 for the full specification.
Tail rest binding
A trailing *ident captures the remaining elements:
def (h, *t) = [1, 2, 3, 4]
assert h == 1
assert t == [2, 3, 4]
def (a, b, c, *rest) = 'hello'
assert a == 'h' && b == 'e' && c == 'l'
assert rest == 'lo' // String slice — type tracks the RHS
The rest binding works against any RHS supporting either
getAt(IntRange) or iterator(). The compiler picks one of three
lowerings — a Stream rewrap that keeps lazy pipelines lazy, an
getAt(IntRange) slice (whose return type drives the rest binder’s
type), or an iterator fallback that supports unbounded sources without
materialising them:
// Lazy iterator — rest stays lazy
def naturals = (1..Integer.MAX_VALUE).iterator()
def (first, *more) = naturals
assert first == 1
assert more.next() == 2 && more.next() == 3
// Stream — pipeline preserved, sequential
def (header, *body) = Stream.of('# title', 'line 1', 'line 2')
assert header == '# title'
assert body.collect(Collectors.toList()) == ['line 1', 'line 2']
Rest binding in head and middle positions
*ident in non-tail position lowers via indexed access against a
sized, indexable RHS:
def (*front, last) = [1, 2, 3, 4]
assert front == [1, 2, 3]
assert last == 4
def (l, *middle, r) = [1, 2, 3, 4, 5]
assert l == 1 && r == 5
assert middle == [2, 3, 4]
def (a, b, *m, y, z) = 1..6
assert a == 1 && b == 2 && y == 5 && z == 6
assert m == [3, 4]
Map-style destructuring
key: ident pairs in the declarator list bind via property access
(map keys, JavaBean getters, or getProperty via the MOP):
def person = [name: 'Alice', age: 30, role: 'admin']
def (name: n, age: a) = person
assert n == 'Alice' && a == 30
// Type ascriptions pin or coerce binding types
def (name: String fullName, age: int years) = person
// Works on JavaBeans too
def (year: y, month: m) = Calendar.instance
Type ascriptions on rest binders
The rest binder may carry a type ascription, mirroring the existing positional form:
def (h, List<Integer> *t) = [1, 2, 3, 4]
def (c, String *cs) = 'hello'
def (l, List<Integer> *m, r) = [1, 2, 3, 4, 5]
def / var binder markers and modifier propagation
For symmetry with switch case patterns and bracket-form declarations,
def and var may appear before any binder; they are equivalent to
omitting a type. Modifiers on the outer declaration propagate to every
binder (including rest and map-style):
def (var a, var b) = [1, 2] // same as: def (a, b) = [1, 2]
def (def a, var b, int c) = [1, 2, 3] // mix and match
final (a, *t) = [1, 2, 3] // both `a` and `t` are final
final (name: n, age: a) = [name: 'A', age: 1] // both `n` and `a` are final
_ for unwanted slots
The "discard" convention, e.g. def (_, y, m) = Calendar.instance,
applies uniformly across every new form. _ is a regular identifier
throughout — no wildcard semantics, no special parser node:
def (_, *t) = [1, 2, 3] // _ binds to the head
def (h, *_) = [1, 2, 3] // _ binds to the rest
def (*_, last) = [1, 2, 3] // _ binds to the front
def (l, *_, r) = [1, 2, 3, 4, 5] // _ binds to the middle
def (name: _, age: a) = [name: 'A', age: 30] // _ binds to the name slot
Compound Assignment Operator Overloading
Groovy 6 lets classes overload the compound assignment operators
(+=, -=, *=, …) independently from their base operators
(GROOVY-11970,
GEP-15).
Historically, x += y always desugared to x = x.plus(y) — creating a
new value and rebinding the variable. That forced mutable types into
inefficient create-and-reassign patterns and made compound assignment
unavailable on final fields and variables.
A class can now define a dedicated *Assign method (plusAssign,
minusAssign, multiplyAssign, and so on). When such a method is
resolved on the receiver, the operator mutates the receiver in place
instead of reassigning the LHS:
class Accumulator {
int total = 0
void plusAssign(int n) { total += n } // mutate in place
Accumulator plus(int n) { new Accumulator(total: total + n) } // create new
}
def acc = new Accumulator()
acc += 5 // calls plusAssign — no reassignment
assert acc.total == 5
Because no reassignment occurs when Assign is used, compound
assignment now works on final fields and variables under
@CompileStatic/@TypeChecked whenever a matching *Assign method
exists on the receiver type. For property LHS, the setter is *not
invoked. The expression value of x op= y is the (mutated) x, not the
return value of *Assign.
The change is strictly additive: if no *Assign method matches, the
legacy x = x.op(y) desugar still applies, so existing code keeps
working. Resolution is direct under static compilation and via the MOP
in dynamic code; *Assign methods are also discoverable as extension
methods or categories. Names can be remapped via
@OperatorRename (e.g.
@OperatorRename(plusAssign='addInPlace')).
The full set of compound assignment operators and their corresponding
*Assign methods (with op fallbacks) is:
| Operator | Assign method | Fallback method |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
See GEP-15 for the full specification.
Nested copyWith for @Immutable and Records
@Immutable and
@RecordType have long offered an opt-in
copyWith (copyWith=true) that returns a new instance with selected
properties replaced. Groovy 6 extends it to nested updates across an
immutable object graph
(GROOVY-12015).
A map key may now be a dotted nested path. Untouched branches are reused
rather than rebuilt, so structural sharing (and is identity) is preserved
transitively:
@Immutable(copyWith = true) class Address { String city, zip }
@Immutable(copyWith = true) class Person { String name; Address address }
def p = new Person('Alice', new Address('NYC', '10001'))
def q = p.copyWith('address.city': 'Boston')
assert q.address.city == 'Boston'
assert q.address.zip == '10001' // carried over unchanged
assert q.name == 'Alice'
Every node on the path must itself provide copyWith(Map) (i.e. be declared
with copyWith=true); otherwise a clear error is raised.
A transactional block form is also generated as sugar over the map form.
Inside the block, old is the original (pre-state) object — aligning with
old in @Ensures/@Contract — so new values can be derived from it, and
prop.modify { } is shorthand for the transform-this-same-field case
(e.g. loginCount.modify { it + 1 }):
def r = p.copyWith {
name = 'Bob' // plain set
address.city = old.address.city.reverse() // derive from pre-state
}
assert r.name == 'Bob'
assert r.address.city == 'NYC'.reverse()
assert p.copyWith { }.is(p) // empty block: identity
The same nested map and block forms work for record-in-record graphs and
mixed @Immutable/@RecordType hierarchies.
Stabilised features
-
Sealed classes, interfaces and traits — introduced as an incubating feature in Groovy 4.0 and stabilised through the 5.x line — are promoted out of incubation in Groovy 6.0 (GROOVY-12008). The full specification for sealed types is in GEP-13: Sealed Types, which was revised alongside the 6.0 graduation.
-
The
groovy-tomlmodule — introduced as an incubating module in Groovy 4.0 — is promoted out of incubation in Groovy 6.0 (GROOVY-12010). Its public API is now covenanted and subject to binary-compatibility checks. -
The
groovy-ginqmodule (GINQ — Groovy-Integrated Query) — introduced as an incubating module in Groovy 4.0 — is promoted out of incubation in Groovy 6.0 (GROOVY-12042). The 6.0 query enhancements are described in GINQ Enhancements. -
The
groovy-contractsmodule is promoted out of incubation in Groovy 6.0 (GROOVY-12038). Its core@Requires/@Ensures/@InvariantAPI is now stable; the newest additions described in Groovy-Contracts Enhancements that build on still-incubating infrastructure — loop-level@Invariant/@Decreasesand@Modifiesframe conditions — remain incubating for this release. -
The
RegexCheckerandFormatStringCheckertype-checking extensions are promoted out of incubation in Groovy 6.0 (GROOVY-12039).RegexCheckernow also validates the pattern argument ofString#matchesat compile time — alongside its existing checks for the=/==operators andPattern.compile/Pattern.matches— so an invalid regex such as'foo'.matches(/?/)is flagged as a compile-time error (GROOVY-12081). -
The macro framework — the
@Macroannotation and related classes — is promoted out of incubation in Groovy 6.0 (GROOVY-12029). -
The
PropertyHandlerSPI for property-style AST transforms is promoted out of incubation in Groovy 6.0 (GROOVY-12030). -
JavaShellis promoted out of incubation in Groovy 6.0 (GROOVY-12026) and gains a newcompileAllTomethod (GROOVY-12025).
Extension method additions and improvements
Groovy provides over 2000 extension methods to 150+ JDK classes to enhance JDK functionality, with new methods added in Groovy 6.
groupByMany — multi-key grouping
Several variants of groupByMany
(GROOVY-11808)
exist for grouping lists, arrays, and maps of items by multiple keys — similar to Eclipse Collections' groupByEach and a natural fit for
many-to-many relationships that SQL handles with GROUP BY.
The most common form takes a closure that maps each item to a list of keys:
var words = ['ant', 'bee', 'ape', 'cow', 'pig']
var vowels = 'aeiou'.toSet()
var vowelsOf = { String word -> word.toSet().intersect(vowels) }
assert words.groupByMany(s -> vowelsOf(s)) == [
a:['ant', 'ape'], e:['bee', 'ape'], i:['pig'], o:['cow']
]
For maps whose values are already lists, a no-args variant groups keys by their values:
var availability = [
'🍎': ['Spring'],
'🍌': ['Spring', 'Summer', 'Autumn', 'Winter'],
'🍇': ['Spring', 'Autumn'],
'🍒': ['Autumn'],
'🍑': ['Spring']
]
assert availability.groupByMany() == [
Winter: ['🍌'],
Autumn: ['🍌', '🍇', '🍒'],
Summer: ['🍌'],
Spring: ['🍎', '🍌', '🍇', '🍑']
]
A two-closure form also exists for transforming both keys and values. See the groupByMany blog post for more examples including Eclipse Collections interop.
Process handling
waitForResult replaces the manual stream/exit-code dance with a single call
(GROOVY-11901):
var result = 'echo Hello World'.execute().waitForResult()
assert result.output == 'Hello World\n'
assert result.exitValue == 0
// With timeout
var result = 'sleep 60'.execute().waitForResult(5, TimeUnit.SECONDS)
Process output streams are now decoded using the system’s native encoding
rather than the JVM default charset, which is what most command-line
tools actually emit. Set -Dgroovy.process.encoding=<charset> to override
it explicitly
(GROOVY-12155).
Asynchronous file I/O
The groovy-nio module adds async file operations on Path that return
CompletableFuture results
(GROOVY-11902).
These compose naturally with Groovy 6’s async/await:
import java.nio.file.Path
// Read two files concurrently
def a = Path.of('config.json').textAsync
def b = Path.of('data.csv').textAsync
def (config, data) = await(a, b)
Regex group extraction
Capturing regex groups was always possible via Matcher (m[0][1] and
friends), but the common case — pull a few named pieces out of a string — meant juggling group indices and guarding against a non-match. findGroups
returns the full match followed by each capture group as a List, so it
pairs naturally with multi-assignment. This isn’t new capability; it just
removes the Matcher boilerplate from one very common case
(GROOVY-11958):
def semver = /(\d+)\.(\d+)\.(\d+)(?:-(.+))?/
def (_, major, minor, patch, qualifier) = '6.0.0-beta-2'.findGroups(semver)
assert [major, minor, patch, qualifier] == ['6', '0', '0', 'beta-2']
// optional qualifier absent: multi-assignment pads with null,
// so the same destructuring works whether or not it matched
def (_all, ma, mi, pa, q) = '4.0.28'.findGroups(semver)
assert [ma, mi, pa, q] == ['4', '0', '28', null]
For the balanced, properly-nested case that a plain regex cannot match on
its own, the new findBalancedGroups GDK method returns the spans between
a matching open/close pair (parentheses, brackets, tags, …)
(GROOVY-12133):
'f(g(x), h(y))'.findBalancedGroups(/\(/, /\)/)
// -> the outer group 'g(x), h(y)' and its nested groups
Locale-aware number formatting
The GDK gains locale-aware currency and percent formatting, plus
locale-aware parsing, wrapping java.text.NumberFormat to cover cases
that sprintf/String.format structurally cannot — currency conversion
and percent scaling/placement
(GROOVY-12147):
assert 1234.5.toCurrencyString(Locale.US) == '$1,234.50'
assert '$1,234.50'.toCurrencyNumber(Locale.US) == 1234.5
The formatting methods are Number.toCurrencyString([Locale]) and
Number.toPercentString([Locale]); the parsing methods are
CharSequence.toNumber(Locale), toCurrencyNumber([Locale]), and
toPercentNumber([Locale]), each returning a Number. The overloads
without a Locale use the default locale, so a localized percent such
as German 94,5 % round-trips via toPercentString() /
toPercentNumber(Locale.GERMANY).
Sliding pairs and consecutive runs
Two new GDK methods cover common "look at neighbours" list operations that previously needed index juggling or a manual fold (GROOVY-12016).
zipWithNext pairs each element with its successor, optionally combining
each pair — handy for deltas, ratios, or detecting transitions:
assert [1, 2, 3, 4].zipWithNext() == [[1, 2], [2, 3], [3, 4]]
// successive deltas via the combiner form
assert [10, 13, 12, 18].zipWithNext { a, b -> b - a } == [3, -1, 6]
groupConsecutive splits an iterable into maximal runs — by equality, by a
key function, or by an adjacency predicate (note: only consecutive equal
keys group together, unlike groupBy):
assert [1, 1, 2, 2, 2, 3, 1].groupConsecutive() == [[1, 1], [2, 2, 2], [3], [1]]
assert ['apple', 'avocado', 'banana', 'cherry', 'citrus', 'date']
.groupConsecutive { it[0] } == [['apple', 'avocado'], ['banana'], ['cherry', 'citrus'], ['date']]
Both have lazy Iterator variants and array overloads, so they compose with
other sequence operations without materialising intermediates.
Allocation-free higher-order methods (java.util.function overloads)
Many of Groovy’s higher-order extension methods now have overloads that accept
java.util.function SAM types — Predicate, Function, Consumer,
BiPredicate, BiFunction, and friends — directly, instead of requiring a
Closure wrapper
(GROOVY-12034,
GROOVY-12054). Passing a
lambda or method reference straight through avoids the per-call Closure
allocation — "fat-free" — and improves interop with Java APIs that already
hand you java.util.function values. The standard overloads mirror the
existing Closure forms one-for-one, covering each/eachWithIndex,
collect/collectEntries, collectMany, groupBy, countBy, split,
takeWhile/dropWhile, inject/injectAll, any/every,
find/findAll, count, and the lazy
collecting/collectingMany/findingAll iterator variants
(GROOVY-12215,
GROOVY-12216).
Map receivers take the two-argument forms (BiFunction/BiPredicate),
and the fold/reduce forms take a BinaryOperator when no initial value
is given, or a BiFunction when one is. Array receivers have the same
twins (GROOVY-12218),
including any/every/count overloads on int[], long[] and
double[] accepting IntPredicate/LongPredicate/DoublePredicate — giving primitive arrays a predicate form of count for the first time.
Under @TypeChecked / @CompileStatic, a method reference or lambda
argument now selects the functional-interface overload when a Closure
overload also applies, so these calls type-check and compile statically
(GROOVY-12214).
import java.util.function.Predicate
import java.util.function.Function
Predicate<Integer> even = n -> n % 2 == 0
Function<Integer, Integer> square = n -> n * n
assert [1, 2, 3, 4].findAll(even) == [2, 4]
assert [1, 2, 3].collect(square) == [1, 4, 9]
A companion curryWith helper (in org.apache.groovy.util.Lambdas, and in
org.apache.groovy.util.Closures for hybrid results usable as both a
Closure and a java.util.function type) right-partials a two-argument SAM
by fixing its second argument, so a shared BiPredicate/BiFunction can be
reused across calls without a capturing closure:
import static org.apache.groovy.util.Closures.curryWith
import java.util.function.BiPredicate
BiPredicate<Integer, Integer> divisibleBy = (n, d) -> n % d == 0
assert [1, 2, 3, 4, 5, 6].findAll(curryWith(divisibleBy, 2)) == [2, 4, 6]
A matching set of incubating …(self, BiPredicate/BiFunction, param)
overloads bake this right-curry in directly — list.find(condition, param)
is equivalent to list.find(curryWith(condition, param)) — covering
collect/collecting, find/findAll/findingAll, any/every, and
count:
def divisibleBy = { n, d -> n % d == 0 }
assert [1, 2, 3, 4, 5, 6].findAll(divisibleBy, 2) == [2, 4, 6] // incubating
Other new extension methods
| Method | Description | Ticket |
|---|---|---|
|
Check whether elements of an Iterable, Iterator, array, or Map
are in sorted order. Supports natural ordering, |
|
|
Named parameters for process configuration: |
|
|
Convert a String, String array, or List into a |
|
|
Create native OS pipelines from a list of commands via
|
|
|
Register a closure to execute asynchronously when a process terminates.
|
|
|
Asynchronous file reading on |
|
|
Asynchronous file writing on |
|
|
Lazy |
|
|
Return a list of all matches of a regex within a |
|
|
Constrain a |
|
|
Return the receiver if it satisfies the predicate, otherwise |
|
|
Explicit-mutability accumulators for |
|
|
|
|
|
No-arg overload of the lazy |
Selectively Disabling Extension Methods
The groovy.extension.disable system property has been enhanced
(GROOVY-11892),
to allow finer-grained control over which
Groovy extension methods are disabled. Previously, setting
-Dgroovy.extension.disable=groupBy would disable all overloads
of groupBy. Now, specific overloads can be targeted by
receiver type or full parameter signature:
| Syntax | Effect |
|---|---|
|
Disables all |
|
Disables only the overload for |
|
Disables all overloads of both methods |
Type names can be simple (Set) or fully qualified (java.util.Set).
This is particularly useful when integrating with libraries like
Eclipse Collections that define
methods with the same name as Groovy’s extension methods but return
different types. For example, Groovy’s groupBy returns lists or maps
from the standard collections library, but Eclipse Collections' groupBy returns a Multimap.
By disabling the Groovy overload only for Lists,
we can still use Groovy’s groupBy on java.util.Map instances.
// disable groupBy only for Lists
// (well, all iterables but only for the Closure variant)
// -Dgroovy.extension.disable=groupBy(Iterable,Closure)
var fruits = Lists.mutable.of('🍎', '🍌', '🍎', '🍇', '🍌')
// Eclipse Collections groupBy → returns a Multimap
assert fruits.groupBy { it } ==
Multimaps.mutable.list.empty()
.withKeyMultiValues('🍎', '🍎', '🍎')
.withKeyMultiValues('🍌', '🍌', '🍌')
.withKeyMultiValues('🍇', '🍇')
// Groovy's groupBy still works on Maps
def result = [a:1,b:2,c:3,d:4].groupBy { it.value % 2 }
assert result == [0:[b:2, d:4], 1:[a:1, c:3]]
Customisable Object Display with groovyToString
Groovy 6 introduces a groovyToString() protocol
(GROOVY-11893)
that lets classes control how their instances appear in string
interpolation, println, collection formatting, and other display contexts.
When a class defines a groovyToString() method returning String,
Groovy uses it instead of toString() for display purposes:
class Foo {
String toString() { 'some foo' }
String groovyToString() { 'some bar' }
}
assert "${new Foo()}" == 'some bar'
assert [foo: new Foo()].toString() == '[foo:some bar]'
Groovy also provides built-in groovyToString extension methods for
collections, maps, ranges, and primitive arrays, giving them their
familiar Groovy formatting (e.g. [1, 2, 3] for int[] rather than
Java’s [I@hashcode). These can be selectively disabled using the
groovy.extension.disable system property if needed.
GINQ Enhancements
GINQ (Groovy-Integrated Query) runs SQL-style queries over in-memory
collections. Groovy 6 adds two conveniences. The examples use the GQ
macro, which returns a queryable result; its sibling GQL returns a
plain List.
groupby … into
GINQ’s groupby clause now supports an into keyword
(GROOVY-11915)
that binds each group to a named variable with aggregate access:
GQ {
from n in [1, 11, 111, 8, 80]
groupby (n % 2 == 0 ? 'even' : 'odd') into g
select g.key, g.count() as count, g.sum(n -> n) as sum, g.toList() as numbers
}
+------+-------+-----+--------------+ | key | count | sum | numbers | +------+-------+-----+--------------+ | even | 2 | 88 | [8, 80] | | odd | 3 | 123 | [1, 11, 111] | +------+-------+-----+--------------+
Set operators
SQL-style set operators
(GROOVY-11919)
for combining query results: union, intersect, minus, and unionall:
def java = ['Alice', 'Bob', 'Carol']
def groovy = ['Bob', 'Carol', 'Dave']
assert GQL {
from n in java select n
union
from n in groovy select n
} == ['Alice', 'Bob', 'Carol', 'Dave']
See the GINQ user guide for the full set of clauses and operators.
CSV Module (incubating)
Groovy 6 adds a new groovy-csv module
(GROOVY-11923)
for reading and writing CSV (RFC 4180) data.
Classes live in the groovy.csv package.
Reading CSV
CsvSlurper parses CSV text into a list of maps, keyed by column headers:
def csv = new CsvSlurper().parseText('name,age\nAlice,30\nBob,25')
assert csv.size() == 2
assert csv[0].name == 'Alice'
assert csv[0].age == '30'
The separator and quote characters can be customised via fluent setters,
and quoted fields follow RFC 4180 rules (embedded commas, newlines, and
doubled quotes). Set useHeader = false to treat the first row as data
and key rows by auto-generated column names. Reader/InputStream/File/Path
overloads of parse are provided alongside parseText.
Writing CSV
CsvBuilder converts collections of maps to CSV:
def data = [
[name: 'Alice', age: 30],
[name: 'Bob', age: 25]
]
def csv = CsvBuilder.toCsv(data)
assert csv.contains('name,age')
assert csv.contains('Alice,30')
Typed parsing and writing
When Jackson is on the classpath, CsvSlurper can parse CSV directly
into typed objects, and CsvBuilder can write typed objects to CSV.
This is particularly useful for CSV since all values are strings — Jackson handles conversion to numeric, date, and other types automatically:
class Sale {
String customer
BigDecimal amount
}
def sales = new CsvSlurper().parseAs(Sale, 'customer,amount\nAcme,1500.00\nGlobex,250.50')
assert sales[0].customer == 'Acme'
assert sales[0].amount == 1500.00
See the Processing CSV user guide for the full API and configuration options.
Typed Parsing and Writing Across Format Modules
Groovy 6 brings typed parsing support across all data format modules, giving a consistent way to convert structured data into typed objects. Given a target class:
class ServerConfig { String host; int port; boolean debug }
Each format can parse directly into it:
// JSON — as coercion (no extra deps)
def config = new JsonSlurper().parseText(json) as ServerConfig
// TOML — Jackson-backed parseTextAs
def config = new TomlSlurper().parseTextAs(ServerConfig, toml)
// XML — Jackson-backed parseTextAs
def config = new XmlParser().parseTextAs(ServerConfig, xml)
| Format | Typed Parsing | Typed Writing |
|---|---|---|
JSON |
JsonSlurper + |
JsonOutput |
CSV (GROOVY-11923) |
CsvSlurper |
CsvBuilder |
TOML (GROOVY-11925) |
TomlSlurper |
TomlBuilder |
YAML (GROOVY-11926) |
YamlSlurper |
YamlBuilder |
XML (see XML Processing Improvements) (GROOVY-11927) |
XmlParser |
— |
|
Note
|
For CSV, TOML, YAML, and XML, the The typed parse/write paths for CSV, TOML and YAML support On the untyped path, |
|
Note
|
YamlSlurper does not resolve YAML anchors and aliases — an alias
arrives as a plain string holding the anchor’s name, and a merge key
(<<) is left as a literal entry rather than merging anything. That comes
from the Jackson YAML backend, where it is a long-standing deliberate
limitation, and it is now documented rather than left to be discovered. The
Processing YAML guide shows both ways round it: @JsonIdentityInfo on the
referenced class for the typed parseAs path, and SnakeYAML’s
SafeConstructor used directly where a document really does rely on anchors
(GROOVY-12358).
|
XML Processing Improvements
The groovy-xml module gains secure-by-default XML processing,
StAX streaming helpers, named-parameter construction for parsers,
and a richer XmlUtil.serialize API.
For typed parsing of XML documents into POJOs, see Typed Parsing and Writing Across Format Modules.
Secure-by-default XML processing
XXE attacks, billion-laughs entity expansion, and unintended network
access via external DTDs all stem from JDK XML factories that ship with
permissive defaults. The front-line Groovy parsers — XmlParser,
XmlSlurper, the static
DOMBuilder.parse(…) overloads, and
XmlUtil.newSAXParser — were already secure-by-default. Groovy 6
closes the remaining gaps in XmlUtil.serialize, FactorySupport, and
DOMBuilder.newInstance
(GROOVY-11979,
GROOVY-11981) — see Breaking changes for the per-API migration knobs. A new XML
security chapter in the user guide documents the contract end-to-end.
StAX streaming helpers
Two new XmlUtil methods stream over XML sources without loading the
whole document into memory
(GROOVY-11979):
events(reader) returns a Stream<XMLEvent>, and
streamElements(reader, [namespaceURI,] localName) pulls each matching
subtree as a small DOM Node. Both run on a hardened XMLInputFactory,
so streaming an untrusted feed is safe out of the box.
Combined with the new HttpBuilder, we can fetch a published Groovy pom from Maven Central and extract its license:
import groovy.xml.XmlUtil
import static groovy.http.HttpBuilder.http
def pom = http('https://repo1.maven.org/maven2')
.get('/org/apache/groovy/groovy/5.0.5/groovy-5.0.5.pom').body
def licenses = XmlUtil.streamElements(new StringReader(pom), 'license')
.map { l -> l.getElementsByTagName('name').item(0).textContent }
.toList()
assert licenses == ['The Apache Software License, Version 2.0']
The lower-level events API suits counts and scans where a DOM Node
per match would be wasteful — counting <dependency> entries across
both dependencyManagement and dependencies is a single filter:
import javax.xml.stream.events.XMLEvent
def deps = XmlUtil.events(new StringReader(pom))
.filter { it.eventType == XMLEvent.START_ELEMENT &&
it.asStartElement().name.localPart == 'dependency' }
.count()
assert deps == 5
Other improvements
| Feature | Description | Ticket |
|---|---|---|
Named-parameter construction |
|
|
|
|
|
Extended JAXP factory access |
FactorySupport now covers all six
JAXP factory types — the existing |
|
Security-feature diagnostics |
FactorySupport now logs a warning when a requested XML parser security feature cannot be set, instead of failing silently, making hardened-parser misconfigurations visible. |
|
Markup builder injection hardening |
The streaming markup builders ( |
Markdown Module (incubating)
Groovy 6 adds a new optional groovy-markdown module
(GROOVY-11940)
for parsing CommonMark Markdown into a
navigable document model. Classes live in the groovy.markdown
package. A common motivating use case is extracting structured
pieces from LLM output — code blocks by language, sections by
heading, links, and tables — but the module is useful anywhere
Markdown is processed programmatically.
MarkdownSlurper
MarkdownSlurper parses Markdown
text into a MarkdownDocument
backed by nested lists and maps. Each node is a Map with a type
key plus type-specific fields:
def doc = new MarkdownSlurper().parseText('# Hello World')
def h = doc.headings[0]
assert h.level == 1
assert h.text == 'Hello World'
The document exposes convenience properties — headings,
codeBlocks, links, tables — that recursively walk the tree,
so nodes nested inside list items or block quotes are still found.
A text property gives a plain-text projection of the whole
document with formatting markers stripped.
The slurper is resilient to malformed input, capping nesting depth
(default 1000) to avoid stack overflow on pathological documents. The
limit is configurable per parser via setMaxNestingDepth(int) (a value
⇐ 0 disables the check) or globally with the
groovy.markdown.maxNestingDepth system property
(GROOVY-12183).
Extracting code blocks
Pulling fenced code blocks by language is a common pattern when consuming LLM output:
def doc = new MarkdownSlurper().parseText(md)
def groovySnippets = doc.codeBlocks.findAll { it.lang == 'groovy' }*.text
Sections
section(headingText) returns the nodes between a given heading
and the next heading of equal or higher level — handy for parsing
structured agent replies:
def doc = new MarkdownSlurper().parseText(md)
def next = doc.section('Next Steps')
assert next[0].type == 'list'
assert next[0].items*.text == ['Item one', 'Item two']
Tables (optional)
GFM-style tables are supported when the
org.commonmark:commonmark-ext-gfm-tables jar is on the classpath.
Call enableTables(true) on the slurper and each row comes back as
a Map keyed by header:
def doc = new MarkdownSlurper().enableTables(true).parseText(md)
def rows = doc.tables[0].rows
assert rows[0].name == 'Alice'
assert rows[1].age == '25'
Supported node types include heading, paragraph, code_block,
list/list_item, block_quote, link, image, text,
inline_code, emphasis/strong, html_block/html_inline,
thematic_break, line breaks, and (with the GFM extension) table.
See the Processing Markdown user guide for the full node schema and additional examples.
Grape: Dual Engine Support (incubating)
Groovy 6 introduces a major evolution of the Grape dependency management system (GROOVY-11871) by adding a second built-in engine alongside the existing Apache Ivy backend. Both engines expose the same @Grab family of annotations and the Grape facade API, so most existing scripts work unchanged.
Engine comparison
| Aspect | GrapeIvy (default) | GrapeMaven (new) |
|---|---|---|
Backend |
||
Engine class |
|
|
Local cache |
|
|
Configuration file |
|
None — use |
|
Ivy configurations; lists supported (e.g. |
Single Maven scope only |
|
Honoured (cache-only resolution) |
Not honoured |
Version wildcard |
Resolves to Ivy’s |
Resolves to Maven’s |
Both engines respect the grape.root system property for relocating the cache root.
Selecting the engine
When both engines are on the classpath, GrapeIvy is selected by default. To switch to GrapeMaven for a specific invocation:
groovy -Dgroovy.grape.impl=groovy.grape.maven.GrapeMaven yourscript.groovy
Set the same property in JAVA_OPTS for a global default.
Custom engines via Java SPI
Custom GrapeEngine implementations are
discovered via the standard java.util.ServiceLoader mechanism by
including a jar with a META-INF/services/groovy.grape.GrapeEngine
entry naming the implementation class. See the user guide for the
full registration protocol.
Migration
Most existing @Grab scripts work unchanged with both engines. The
notable differences when moving from GrapeIvy to GrapeMaven:
-
Multi-value
conf:parameters (e.g.conf:['default','optional']) are not supported — GrapeMaven uses a single Maven scope. -
@GrabConfig(autoDownload=false)is not honoured — point@GrabResolverat a local-only repository for similar semantics. -
~/.groovy/grapeConfig.xmlsettings are GrapeIvy-only — register custom repositories via@GrabResolverorGrape.addResolverinstead.
Switching from GrapeMaven back to GrapeIvy is generally straightforward since GrapeIvy’s defaults are more permissive.
See the Grape user guide for the full per-engine specification, custom-engine registration steps, per-engine logging configuration, and detailed migration walkthrough.
Coordinate shorthands for the grape CLI
The grape install command line tool now accepts the same Maven and
Ivy coordinate shorthands as @Grab and the
Grape facade, in addition to its original
positional form
(GROOVY-12004):
grape install com.example foo 1.2.3 # positional (as before)
grape install com.example:foo:1.2.3 # Maven shorthand
grape install com.example:foo:1.2.3:jdk15@zip # Maven shorthand with classifier and extension
grape install com.example#foo;1.2.3 # Ivy shorthand
The static Grape.grab(String) Java API has been broadened along the
same lines: in addition to its existing endorsed-module notation it
now recognises both shorthand forms, dispatching to grab(Map) once
the coordinate has been parsed.
Hardening
Grape resolution has been hardened against several real-world failure modes (GROOVY-12005):
-
Coordinate values containing
..path segments are now rejected (should not contain '..') in both the Ivy and Maven engines, guarding against path traversal in the cache layout (GROOVY-12073). -
A corrupt or truncated JAR (e.g. a CDN
429/partial response) no longer aborts the whole@Grab; extension-method scanning skips the bad JAR with a warning while the remaining artifacts continue to register. -
Strict local-
m2and cached-grapes resolvers reject half-populated POM-only stubs and corrupt artifacts so a poisoned cache entry is re-fetched rather than silently used; a relaxed configuration is available for environments that need the previous lenient behaviour. -
A checksum mismatch now fails resolution in the Maven engine rather than logging a warning and using the artifact anyway, matching the Ivy engine’s long-standing behaviour (GROOVY-12265). Artifacts published without any checksum continue to resolve, as they do with the Ivy engine, and
@GrabConfig(disableChecksums=true)(or-Dgroovy.grape.disableChecksums) still skips verification entirely. -
A resolver root using a plaintext protocol, such as a
@GrabResolvernaming anhttp://repository, is now reported since artifacts fetched from it can be read or modified in transit (GROOVY-12266).-Dgroovy.grape.insecureProtocolPolicyselectsfailto reject such a resolver,warn(the default) to log it and add the resolver anyway, orignoreto skip the check; roots naming a loopback host are exempt under every policy. The check applies to both engines, since every route to adding a resolver passes through the same facade. -
grape uninstallno longer deletes outside the module cache. The jar to delete wasnew File(jardir, name)withnametaken from the module’s cached Ivy descriptor unchecked, so a descriptor whose artifact name carried a path separator or a..segment deleted a file above the module’s jars directory. Deletion is now skipped, with a warning, unless the resolved file is inside that directory, checked on canonical paths so a..or a symbolic link along the way cannot carry it out (GROOVY-12369).
Platform Logging
Groovy’s own diagnostics can now be routed, filtered, or silenced
through standard JVM logging — no more hard-coded System.err output
you can’t control.
Groovy 6 replaces direct System.err output with the JDK’s
Platform Logging API (java.lang.System.Logger)
for internal errors and warnings
(GROOVY-11886).
This means Groovy’s diagnostic messages can now be controlled
through standard JVM logging configuration.
By default, messages still appear on the console via java.util.logging,
but users can plug in any logging framework (SLF4J, Log4j2, etc.)
by providing a System.LoggerFinder implementation on the classpath.
Configuring logging
Groovy’s command-line tools resolve logging configuration in the following order (first match wins):
-
A file specified via
-Djava.util.logging.config.file=… -
A user configuration at
~/.groovy/logging.properties(auto-discovered by Groovy at startup) -
The JDK default at
$JAVA_HOME/conf/logging.properties
For example, to enable verbose Grape logging, create
~/.groovy/logging.properties with:
handlers = java.util.logging.ConsoleHandler
java.util.logging.ConsoleHandler.level = ALL
groovy.grape.Grape.level = FINE
Loggers are organised by module (Core, Grape, Ant, Console, GroovyDoc, JSON, Servlet, Swing, Testing) — see the logging guide for the full list of logger names and their levels.
Compile-time caller locations with @Log4j2
Application logging has the opposite problem: a logging framework finds
the caller of a log statement by walking the stack, and every frame
Groovy’s runtime inserts between the two — metaclass dispatch, the
reflective cold dispatch tier, GraalVM’s method-handle interpreter in a
native image — makes that answer wrong, so a pattern using %C/%M/%L
names the runtime rather than the code that logged. Log4j2 offers no skip
list to configure, but it does let a caller supply the location, and an
AST transform knows that location exactly, because it is the one writing
the call.
@Log4j2 gains a staticLocation
option that does just that
(GROOVY-12378):
@Log4j2(staticLocation = true)
class OrderService {
void place(Order order) {
log.info "placing $order"
}
}
The statement is emitted as
log.atInfo().withLocation(loc).log("placing $order"), where loc is a
StackTraceElement for that statement held in a synthetic static field. So
%C, %M and %L report OrderService.place and its line however the
call was dispatched — and Log4j2 skips its stack walk altogether, which is
the expensive part of resolving a location. A leading Marker or trailing
Throwable argument is routed through withMarker/withThrowable at run
time, since the builder does not extract a message’s throwable itself.
It is opt-in because the call shape changes and LogBuilder requires
Log4j 2.13+, which the transform checks at compile time. For the wider
picture of which dispatch paths make the caller invisible, and the
java.util.logging and Logback settings that compensate on the paths a
transform cannot rewrite, see the invokedynamic guide and
Other Core API Changes.
Joint Compilation Stub Improvements (incubating)
This work is specified by GEP-21.
When mixing Groovy and Java sources, Groovy generates Java stubs so
javac can compile Java files that reference Groovy classes.
Historically, those stubs were generated before AST transforms ran, so
members contributed by transforms such as @TupleConstructor,
@Immutable, or @Builder were absent from the stubs and Java code
relying on them failed to compile.
Groovy 6 extends the AST transform framework so that opt-in transforms can contribute member signatures (constructors, methods, fields) to the generated stubs. The change is strictly additive — transforms that do not opt in behave exactly as before (GROOVY-11976).
For example, a Groovy class using @Immutable:
// UserAccount.groovy
@groovy.transform.Immutable
class UserAccount {
String name
int age
}
is now visible to Java callers during joint compilation:
// Caller.java
UserAccount user = new UserAccount("alice", 30);
A wide range of built-in transforms have been updated to contribute
stubs, including @AutoClone, @AutoImplement, @Bindable, @Builder,
@Delegate, @EqualsAndHashCode, @ExternalizeMethods, @Final,
@Immutable, @IndexedProperty, @InheritConstructors, @Lazy,
@ListenerList, @MapConstructor, @NamedVariant, @RecordType,
@Singleton, @Sortable, @ToString, @TupleConstructor, and
@Vetoable. Custom transforms can opt in via one of three shapes
(annotation attribute, marker interface, or split-transform classes).
See GEP-21 for the full specification.
In addition:
-
Native records now appear as native records in stubs (GROOVY-11974)
-
Static methods declared in Groovy traits are now correctly visible from Java callers during joint compilation (GROOVY-11899)
-
Groovy sealed types now carry the correct
sealed/permits/non-sealeddeclarations in stubs (GROOVY-12008)
JUnit 6 Support
Groovy 6 updates its testing support to include JUnit 6 (GROOVY-11788). JUnit 6 (Jupiter 6.x) is the latest evolution of JUnit, building upon the JUnit 5 platform.
Using JUnit 6 with Groovy
For most users, no special Groovy module is needed — simply add the standard JUnit Jupiter dependencies to your Gradle or Maven project and write tests in Groovy as usual:
// build.gradle
testImplementation 'org.junit.jupiter:junit-jupiter:6.0.3'
The groovy-test-junit6 module
The new groovy-test-junit6 module
provides additional capabilities for users who need them:
-
Running individual test scripts — execute a Groovy test file directly as a script without a full build tool setup
-
Conditional test execution annotations — scripting support for JUnit Jupiter’s conditional execution annotations (GROOVY-11887), allowing conditions to be expressed as Groovy scripts
-
@ForkedJvm— runs an annotated test method or class in a freshly forked JVM, with optionalsystemProperties,jvmArgs, andinheritPropertiesattributes. Useful for tests that need a clean process state, an isolated system property, or specific module-access flags (e.g. `--add-opens=…). `inheritPropertiesaccepts exact property names or prefix patterns ending in(e.g. ’spock.'`) and propagates matching properties from the parent JVM to the child — handy when the build (Gradle, Maven) sets a property the forked test still needs (GROOVY-11997). -
@ExpectedToFail— inverts the pass/fail outcome of a test: the test passes when it throws (optionally an exception of a given type or with a message containing a given substring), and fails when it does not. Composes with@ForkedJvmin either declaration order (GROOVY-11997). -
Convenient dependency management — a single dependency that transitively pulls in compatible JUnit 6 libraries
Similarly, the existing groovy-test-junit5 module continues
to provide the same capabilities for JUnit 5 users.
GroovyConsole: Script Arguments
GroovyConsole now supports setting script arguments directly from the UI
(GROOVY-11895).
Previously, users had to manually set args within their script
as a workaround. A new Set Script Arguments option in the Script menu
lets you specify space-separated arguments that are passed to the script.
Groovysh Enhancements
Inline images and charts with /img
The new /img command renders an image inline in the REPL using JLine’s
terminal-graphics support (Sixel, Kitty, or iTerm2 protocols, auto-detected)
(GROOVY-12003).
The argument can be a local file path, an http(s):// URL, or a Groovy
variable reference using the standard $ syntax:
groovy> /img chart.png
groovy> /img --width=80 https://example.com/diagram.png
groovy> img = new java.awt.image.BufferedImage(200, 100, 1)
groovy> /img $img
When the argument resolves to a Groovy value, /img accepts a
BufferedImage or RenderedImage directly, anything with a
createBufferedImage(int, int) or toBufferedImage(int, int) method
(duck-typed — no compile-time dependency), or a javax.swing.JComponent
that is laid out and painted at the requested size. This means popular
charting libraries can be rendered in the REPL without saving to a file
first. JFreeChart matches the createBufferedImage shape, Smile’s
Figure matches toBufferedImage, Smile’s Canvas/MultiFigurePane
and Orson Charts' Chart3DPanel match the JComponent path, and
XChart’s BitmapEncoder.getBufferedImage(chart) returns a plain
BufferedImage. For example, a JFreeChart bar chart:

Or feeding CSV through the existing /slurp command into an XChart
line plot:

The --width and --height options are in terminal character cells for
raw-pixel inputs and in source-image pixels for inputs that generate the
image at the requested size; aspect ratio is preserved by default. If
the active terminal doesn’t speak a supported graphics protocol (common
cases: macOS Terminal.app, VS Code’s built-in terminal, JetBrains
terminals, plain Windows console), /img prints a [image: WxH, label]
summary instead — pass --gui to open a Swing window with the image
regardless. The command requires the java.desktop module at runtime;
on a JVM where that module is unavailable, /img is not registered.
Markdown support for /slurp
The /slurp command — a shortcut for parsing files into shared
variables using Groovy’s various slurpers (JSON, XML, YAML, CSV, TOML,
properties) — now also handles Markdown
(GROOVY-12002).
Files with .md or .markdown extensions are routed through the new
MarkdownSlurper from groovy-markdown (when it is on the
classpath), yielding a MarkdownDocument that exposes structured
elements — headings, code blocks, links, tables — via the iterator and
convenience properties documented in that module. Combined with /img
above, this enables short pipelines from Markdown reports into rendered
charts directly in the REPL.
Case-insensitive syntax-highlight names
Highlight-style names supplied to /print -s <FORMAT> (and the
/highlighter switch) are now resolved case-insensitively, so json,
JSON, and Json all select the same nanorc grammar
(GROOVY-12018).
Theming Improvements
To match recent theming improvements to the Groovy website — which gained light, dark, and "follow system" options earlier in the year — Groovy 6 brings consistent light/dark/custom theming to its documentation tooling and to GroovyConsole.
Theming is more than visual polish or matching personal preference: it is also an accessibility improvement. Readers with light sensitivity, low-vision users running high-contrast palettes, and anyone working in a low-light environment all benefit from a tool surface that respects the system colour scheme rather than forcing one on them.
GroovyDoc
GroovyDoc gained a -theme flag controlling the output palette
strategy
(GROOVY-11947):
-
auto(default) — follow the reader’s OSprefers-color-scheme. Both palettes are emitted; the browser picks at view time. -
light/dark— lock the output to a single palette regardless of OS preference.
The default stylesheet has been refactored around semantic CSS
custom properties (--fg, --bg, --link, --bg-panel, etc.), so
author stylesheets supplied via --add-stylesheet can reference the
same tokens and become theme-aware automatically — the same hook
that enables fully custom palettes. When Prism.js syntax
highlighting is enabled, the highlight theme swaps in sync with the
page palette
(GROOVY-11946).



docgenerator (Groovy user guide)
The same theming model has been extended to docgenerator, the
tool that produces the Groovy user guide and other
distribution-bundled documentation. Output supports light, dark,
and custom themes, following the OS preference by default and
sharing the GroovyDoc CSS-custom-property approach so that custom
stylesheets remain palette-aware.


GroovyConsole
GroovyConsole now offers light, dark, follow-system, and custom theme options, applied across the editor pane, output pane, and surrounding chrome. The active choice is persisted between sessions.



GroovyDoc Enhancements
Groovy 6 brings GroovyDoc substantially closer to modern Javadoc
feature parity and adds a few enhancements of its own. The two
headline items are support for JEP 467 Markdown doc comments and
JEP 413 {@snippet} code snippets, both working in .groovy and
.java sources.
Markdown doc comments (JEP 467)
A run of /// line comments can now be used as a doc comment whose
body is CommonMark Markdown
(GROOVY-11542):
/// # Greet
///
/// Returns a friendly greeting. Inline tags still work:
/// {@link String} and {@code greet}.
///
/// ```groovy
/// assert greeter.greet('world') == 'Hello, world'
/// ```
///
/// @param name the subject to greet
/// @return the greeting
String greet(String name) { "Hello, $name" }
Headings, bullets, fenced code blocks, emphasis, and inline code all
render as you would expect. Headings are shifted down two levels
(so # becomes <h3>), fitting under each page’s existing title
structure. Inline Javadoc tags continue to work inside Markdown
bodies; tag-like text inside fenced code blocks stays literal.
Traditional /** … */ Javadoc blocks still work and can coexist
with /// comments in the same source file.
See JEP 467 and CommonMark for the full syntax and semantics.
Code snippets via {@snippet}
The JEP 413 {@snippet} tag is now supported
(GROOVY-11938)
in three forms. Inline body:
/**
* {@snippet lang="groovy" :
* def greet(name) { "Hello, $name" }
* greet('world')
* }
*/
External reference to a file in the package’s snippet-files/
directory (a region can also be selected via region="name"):
/** {@snippet file="GreetExample.groovy"} */
Markup comments inside snippet bodies let authors highlight, replace, or link to portions of the code without editing the source itself:
/**
* {@snippet lang="groovy" :
* def greet(name) { "Hello, $name" } // @highlight substring="greet"
* // @link substring="Greeter" target="Greeter"
* Greeter.instance.greet('world')
* }
*/
@highlight, @replace, and @link all support substring=,
regex=, and region= attributes. See
JEP 413 for the full markup
reference.
Two author-facing conveniences apply to the external form: the
language class is inferred from the file’s extension when no
explicit lang="…" is given, and a leading license or copyright
header in the referenced file is stripped from the rendered output
(opt out with keepHeader=true). See the
dochome:groovydoc.html[Groovydoc
documentation] for the full list of recognised extensions and the
header-detection heuristic.
Syntax highlighting and dark mode
Two new generator flags make the rendered output immediately more modern:
groovydoc -syntaxHighlighter prism -theme auto ...
-syntaxHighlighter=prism bundles
Prism.js for client-side syntax highlighting
of {@snippet} blocks and Markdown fenced code. Groovy, Java, XML,
JSON, YAML, TOML, SQL, CSV, Markdown, JavaScript, and regex are
supported out of the box.
-theme controls the palette strategy
(GROOVY-11947):
-
auto(default) — follow the reader’s OSprefers-color-scheme. Light and dark palettes are both emitted; the browser picks at view time. Syntax highlighting swaps themes in sync (GROOVY-11946). -
light/dark— lock the output to one palette regardless of OS preference.
Under the hood, the default stylesheet has been refactored around
semantic CSS custom properties (--fg, --bg, --link,
--bg-panel, etc.). Author stylesheets supplied via
--add-stylesheet can reference the same tokens and become theme-aware
automatically.
Highlighting legacy <pre> blocks
The new -preLanguage <lang> option (CLI) / preLanguage="lang"
attribute (Ant task) sets a default Prism language for unattributed
<pre> blocks in doc comments, so legacy code samples get
highlighted without source edits
(GROOVY-11950).
See the
dochome6:groovydoc.html[Groovydoc
documentation] for the rewrite rules and the per-block opt-out.
Feature summary
Smaller improvements that collectively close the long-standing gap with modern Javadoc:
| Feature | Description |
|---|---|
Class hierarchy tree pages |
|
Script documentation |
Top-level scripts now get documented; leading |
|
Members annotated with |
|
Inlines compile-time constants. Supports |
|
Pulls in the parent class’s/interface’s corresponding documentation — including from external (JDK) classes, resolved from the local JDK source archive (GROOVY-3782, GROOVY-11988) |
|
Recognised with proper heading labels (e.g. "Implementation Requirements:" rather than raw tag names) (GROOVY-11945) |
|
Annotation references are only emitted when the annotation type
itself is |
|
Per-package directories are copied verbatim to the output for sibling images, diagrams, and external snippet sources (GROOVY-5986) |
|
Add a custom stylesheet alongside the default (contrast with
|
Optional-page toggles |
|
Ant |
JPMS-style module segment in external-link URLs for JDK 9+ Javadoc layouts (GROOVY-11682) |
Non-zero exit on errors |
CLI returns a non-zero exit code when source files fail to parse, so CI jobs fail rather than silently succeed (GROOVY-9057) |
Output stays inside the destination |
A generated class or package page is written only where the normalised
path resolves within |
|
The verbatim mirror of a package’s |
Miscellaneous fixes |
No spurious |
Build tool support
The new options above are exposed through the GroovyDoc CLI
(groovydoc) and the Ant task. Gradle’s built-in groovydoc
task currently lags behind both surfaces and does not yet expose
every new flag.
Until that gap closes, we recommend either driving GroovyDoc via the Ant task from Gradle, or replicating the approach used by the Apache Groovy and Grails builds themselves — both projects wrap GroovyDoc in their own plugin/build glue to access the full feature set.
Improved Annotation Validation
Misplaced annotations that used to compile silently are now caught at compile time. Groovy 6 closes gaps in annotation target validation for Groovy-specific annotation targets (GROOVY-11884), and to now be fully compliant with JLS 9.6.4.1 (GROOVY-11838) for normal Java targets.
Previously, annotations could be placed on import statements and loop statements without validation — for example:
@Deprecated import java.lang.String
would compile without error even though @Deprecated does not target imports.
The compiler now enforces that only annotations explicitly declaring Groovy-specific targets are permitted in these positions. A new @ExtendedTarget meta-annotation with an ExtendedElementType enum defines two Groovy-only targets:
-
IMPORT— for annotations valid on import statements (e.g. @Grab and related annotations, @Newify, @BaseScript) -
LOOP— for annotations valid on loop statements (e.g. @Invariant, @Decreases, @Parallel)
Annotations without the appropriate @ExtendedTarget declaration
are now flagged as compile errors when applied to these constructs.
This is a breaking change for code that
previously relied on the lenient behaviour.
Java Compatibility
Groovy 6 narrows the gap with Java syntax in a few places, so that code copied from Java sources compiles without the previously required Groovy-specific rewrites.
Module import declarations
Groovy 6 supports Java’s module import declarations (GROOVY-11896), giving scripts and classes a concise way to pull in every public type exported by a named module with a single statement:
import module java.sql
def conn = DriverManager.getConnection('jdbc:h2:mem:test')
def stmt = conn.createStatement()
An import module java.sql is equivalent to a star import
(import pkg.*) for every package the module exports, and also
covers packages reached via requires transitive. For example,
a single import module java.sql makes Connection and
DriverManager directly available, along with types like
javax.xml.transform.Source (from java.xml) and
java.util.logging.Logger (from java.logging).
Module imports work with system modules (JDK modules like
java.base, java.sql, java.desktop), JPMS modules from modular
JARs on the classpath, and automatic modules. Explicit single-type
imports take priority, so naming conflicts can be resolved by
adding a specific import:
import module java.desktop
import java.util.List
// The explicit single-type import wins over
// the module-expanded java.awt.List
assert List.name == 'java.util.List'
The module keyword is context-sensitive — it remains usable as
a variable, method, or class name. Wildcard
(import module java.base.*) and alias
(import module java.base as jb) forms are not supported.
See JEP 511 for the corresponding
Java language feature.
Array initializers in annotations
-
Array initializer syntax (
{…}) is now accepted in annotation attribute values, matching Java — e.g.@A(name={})and@A(name={""})on a class. Previously these required explicitnew String[]{…}syntax (GROOVY-11866). -
Array initializer syntax is also accepted in annotation default values within
@interfacedeclarations — e.g.String[] value() default {}(GROOVY-11845).
Labelled break targeting non-loop blocks
Labelled break targeting a labelled (non-loop) block now correctly
exits that block, matching Java. Previously the break exited only
the innermost enclosing loop, causing statements after the labelled
block to re-execute
(GROOVY-6844,
GROOVY-7463).
instanceof pattern variable flow scoping (JEP 394)
Pattern variables introduced by instanceof (and Groovy’s native
!instanceof) now follow Java’s flow scoping rules
(JEP 394): the variable is in scope
exactly where the compiler can prove the test succeeded
(GROOVY-12242).
In particular, the Java-idiomatic early-return form now works:
def describe(o) {
if (!(o instanceof String s)) {
return 'not a string'
}
s.toUpperCase() // ok: the test must have succeeded to get here
}
Flow scoping composes through !, && and || in the condition:
o instanceof String s && s.length() > 4 binds s for the right-hand
side of the && and for the if-block, a negated test binds the
else-block, and the variable remains bound after the whole statement
when the branch it is not bound in cannot complete normally (as
above). Where flow scoping says the variable is not bound — the
else-block of a plain instanceof test, or anywhere after
o instanceof String s || other — a same-named declaration is now
permitted and introduces an ordinary new local. Reads outside the
flow scope are compile-time errors under @TypeChecked /
@CompileStatic and dynamic property lookups in dynamic code; this
is a behaviour change for code that relied on the previous leaking
scope (see Breaking changes). while
conditions support the short-circuit and loop-body bindings; Java’s
introduction of the variable after a loop whose body cannot
complete normally is not currently applied.
Intersection-type casts for lambdas, method references and closures
Groovy 6 accepts Java’s intersection-type cast syntax on lambdas, method references and closures, so that a single expression can be declared to satisfy several interface contracts at once (GROOVY-11998):
Runnable r = (Runnable & Serializable) () -> println('hello')
The cast carries through static compilation: when one of the bounds
is Serializable, the generated synthetic class implements
Serializable and emits the required $deserializeLambda$ plumbing,
so the resulting lambda can be persisted, sent across JVMs, or used
in frameworks that rely on serialised lambdas (such as JPA Criteria
APIs and some distributed-computing libraries). The same applies to
method references
(GROOVY-11993) — a (Supplier<String> & Serializable)-typed text::trim survives a
round-trip through ObjectOutputStream/ObjectInputStream.
The non-functional bounds are passed through to
LambdaMetafactory as marker interfaces, so reflective checks
(instanceof Serializable, instanceof Cloneable, …) on the
resulting object behave as Java programmers expect.
In addition to the Java-style cast, Groovy’s as coercion accepts the
same intersection form — handy when coercing a closure rather than a
lambda:
def r = { println 'hello' } as (Runnable & Serializable)
Generic type syntax
Groovy 6 closes the remaining gaps between Groovy’s and Java’s handling of generic type syntax (GROOVY-12319), so generic declarations copied from Java source mean the same thing in Groovy. Three strands:
Rare types and nested parameterizations. The JLS 4.5 enclosing type
is now carried on the ClassNode itself, so Outer<String>.Inner is
distinguished from Outer.Inner and written into the JVMS Signature
attribute in its nested form when — and only when — the enclosing type
is parameterized. Map.Entry and Cell.class are recognised as type
names rather than parameterizations (JLS 15.8.2), and method and
constructor references such as Iterable<String>::iterator are no
longer mistaken for class literals.
Type variables identified by declaration. A GenericsType now records
which type-parameter section declared it, so two placeholders both named
T are distinguished when they come from different declarations — a
method’s T can no longer be stored in a field typed by the class’s
T. An unbounded type-parameter declaration no longer inherits a
shadowed variable’s erasure, and duplicate names within one
type-parameter section are rejected.
JLS well-formedness. Every compilation unit — dynamic Groovy and
scripts included — now requires generic types to be well-formed by the
same JLS rules Java applies. Constructs such as generic array creation
(new T[n]), a generic class extending Throwable, a wildcard in a
class instance creation or superinterface, a primitive type argument, a
parameterized class literal (Foo<String>.class), instantiating or
naming a type variable, and catching a type parameter (JLS 14.20) are
compile errors where Groovy 4 and 5 accepted them by silently falling
back to the erasure. Diagnostics for these — and for static references
to a type parameter, wildcard bounds, instanceof of rare and array
types, and duplicate generic interfaces — are worded to match javac.
This tightening is a behaviour change; see
Breaking changes, and the Generics section of
the dochome6:core-object-orientation.html#generics-well-formedness[object
orientation guide] for the full table of rejected constructs with their
workarounds.
What has not changed is inference: dynamic Groovy still does not check
generic invariance, inference, or unchecked conversions — those remain
the domain of @TypeChecked and @CompileStatic.
Dynamic Groovy in GraalVM Native Images (incubating)

Dynamic Groovy in a GraalVM native image has historically been a
non-starter: native image restricts exactly the operations Groovy’s
runtime leans on — defining classes at runtime and retargeting
invokedynamic call sites — so only fully statically-compiled code
stood much chance. Groovy 6 removes those obstacles piece by piece,
so dynamically dispatched Groovy code can run ahead-of-time compiled:
-
AOT link mode for
invokedynamicdispatch: native image supports everyjava.lang.invokebuilding block Groovy’s indy runtime composes except retargeting an already-linked call site. In AOT link mode — detected automatically inside a native image, or forced on a normal JVM with-Dgroovy.indy.aot.link=true— every call site links once, permanently: inline-cache lookup, freshness check and invocation run as ordinary compiled code, and metaclass changes advance a global stamp that triggers re-selection instead ofSwitchPointinvalidation (coarser than the scoped invalidation of GROOVY-12191, but never stale). The reflective cold dispatch tier (GROOVY-12137) serves as the AOT steady state, since reflective dispatch runs in AOT-compiled stubs while runtime-created method handles would fall back to the much slower native method-handle interpreter. Because the bootstrap method referenced by existing class files is unchanged, already-published indy-compiled jars become native-capable without recompilation (GROOVY-12234). -
Packed closures without runtime class definition: the GEP-27 packed-closure machinery (see Other Core API Changes) now links its dispatchers through compiler-emitted
LambdaMetafactorycall sites instead of a runtime reflective lookup plus programmaticLambdaMetafactory— the two operations ahead-of-time runtimes restrict — so packed closures run in a native image with no extra reachability metadata (GROOVY-12227). -
Argument-less enum constants are now created with a direct constructor call rather than reflection, so such enums initialize in native images without reachability metadata for the enum (see Breaking changes; GROOVY-12240).
-
Hidden-class definition soft-fails: where Groovy now prefers hidden classes for its runtime-generated helpers (see Other Core API Changes), environments that forbid runtime class definition fall back transparently (GROOVY-12223).
-
Reachability metadata ships in the jars: building an image of a dynamic Groovy program used to require a native-image agent run just to capture what Groovy’s own runtime reflects over; without it the image built and then failed initialising
GroovySystem. The groovy jar now carries that metadata underMETA-INF/native-image/org.apache.groovy/groovy/, wherenative-imageand the GraalVM build plugins find it: anative-image.propertiespinning run-time initialisation for the runtime classes that decide how dispatch links (GROOVY-12366), the resources the runtime reads, and areachability-metadata.jsonthat the build generates from what the runtime is known to do rather than from a recording: the metaclass registry’s bootstrap, the method handles the invokedynamic machinery obtains, the Groovy-owned types that get a metaclass, and theBeanInfo/Customizerprobes the runtime expects not to find. Every entry is conditional on the class performing the access being reached, so an image pays only for what it uses. The default Groovy method adapters (dgm$N) are now constructed directly through a generated factory instead of being loaded by name, so all of them are reachable without metadata;-Dgroovy.dgm.factory=falsepassed tonative-imagerestores name-based loading and keeps only the adapters an agent recording saw, for a smaller image. Modules that register extension methods (groovy-nio,groovy-xml,groovy-sql,groovy-swingand others) ship their own metadata alongside. The agent is still the way to record what depends on the application, its own classes and the JDK types it calls dynamically; run it with-Dgroovy.indy.aot.link=trueso the JVM links call sites as the image will. A native probe undersubprojects/tests-nativeand a CI check keep the shipped metadata in step with the runtime. GraalVM for JDK 25.0.4 or later is required; earlier releases carry a substitution targeting a method Groovy 6 no longer has (GROOVY-12365). -
The module metadata covers more than extension registration:
groovy-nioships theWatchEvent$Modifierentry thatPathintrospection needs, which a JVM agent run cannot record and so has to be hand-listed (GROOVY-12394), andgroovy-concurrent-java— which repackages the async runtime classes without the groovy jar, and so without the groovy jar’s metadata — carries its own copy of the entries registering the virtual-thread executor and theScopedValuelookups, so a pure-Java image gets them too (GROOVY-12380). The native probe builds its image overgroovy-nio,groovy-xmlandgroovy-jsonalongside core, which is what surfacedgroovy-xml’s JAXP `ServiceLoaderlookups and `groovy-json’s service-loaded string service as gaps in the first place (GROOVY-12379). -
Image frames are recognised as runtime frames: inside a native image GraalVM runs dynamically built
MethodHandlechains in its own interpreter and reflects through its own accessors, socom.oracle.svm.*frames sit between a Groovy call and its real caller.ReflectionUtils.getCallingClass— which is howgetBundleand Grape find the calling class — andStackTraceUtils.sanitizenow treat those as runtime frames, so a resource bundle resolves against the caller that asked for it and a sanitized stack trace is not padded with image internals (GROOVY-12362).
A note on class-initialization policy: recipes written for earlier Groovy
versions often passed a blanket --initialize-at-build-time (with no
argument, meaning every package) to coax a statically compiled script
through. Don’t carry that over. Run-time initialization is already the
default for application classes in current native-image releases, and it is
what the AOT link mode expects; forcing Groovy’s runtime to initialize at
build time instead pulls its internal state into the image heap, where
native-image rejects live objects such as running threads and seeded
Random instances. Leaving the flag off is both shorter and correct.
A note on caller location: inside a native image, dynamically dispatched
calls leave GraalVM’s method-handle interpreter and reflection frames
visible to StackWalker and stack traces, whichever dispatch path is
used, so loggers report the runtime rather than the calling method as the
source. java.util.logging needs the packages Groovy and GraalVM insert
listed in jdk.logger.packages at image build time (a run-time -D is
ignored), Logback needs them added to its framework packages, and Log4j2
has no equivalent hook; statically compiled code is unaffected. The
invokedynamic guide has the exact settings
(GROOVY-12354).
A note on the JDK’s own AOT cache: JDK 25’s AOT cache (JEP 514) is a
different mechanism that applies to Groovy programs on an ordinary JVM, and
needs nothing configured in Groovy — the recipe is the JDK’s own training
run plus -XX:AOTCache. Measured on the small dynamic program the build uses
to check native images, nearly every class comes from the cache rather than
the jars, the hidden classes for the lambdas in the runtime’s own classes
come with them, and startup plus run drops by roughly 40%. What it does not
cover is dispatch: Groovy’s call sites are bootstrapped by IndyInterface
and JDK 25 archives only sites the JDK’s own bootstrap methods link, so every
dynamic site still links on its first call in each run. Three JDK
constraints bite in practice — the class path must be jars rather than
directories, it must be unchanged between the training run and the cached
runs, and classes the Groovy compiler defines at run time cannot be archived,
so compile with groovyc into a jar rather than running a script. The
invokedynamic guide has the full recipe
(GROOVY-12381).
Running on Non-HotSpot Runtimes
The work on native images turned up a second, related assumption: Groovy’s runtime had grown a handful of dependencies on being a HotSpot JVM built from a current JDK. Those matter for any runtime that implements the Java class library without being a JDK — Android’s ART most of all, which is where most of the following surfaced. Groovy 6 removes them, so the runtime comes up and dispatches dynamically on such a runtime rather than failing during class initialisation.
None of this makes Groovy 6 a fully-supported, tested Android platform. What changed is that these are no longer in the way. While you can build Android apps using Groovy 6 with a shim architecture, we anticipate full support in a future Groovy version.
| Change | Description |
|---|---|
Java version detection |
|
Accessibility without a module system |
The VM plugin’s accessibility rules consulted |
Type-use annotations without |
The Java 8 plugin reads type-use annotations through |
Hidden classes |
The direct invokers (see Other Core API Changes)
check for Android before touching |
|
|
|
ART does not run the handler in a |
Lazy |
|
Compiler types out of the runtime |
|
DGM adapter initialisers |
The generated |
R8 keep rules |
|
|
The |
Compiler Diagnostic Output
A compiler error is read by two very different audiences, and until now
Groovy rendered it for only one of them. Groovy 6 adds
CompilerConfiguration.errorFormat to choose the rendering
(GROOVY-12312).

FULL is the default and is unchanged, byte for byte: four lines per
diagnostic, ending in the source line and a caret under the offending
column. It is the right thing for a person reading a terminal.
SHORT emits one line per diagnostic in the shape every editor, CI log
parser and coding agent already understands:
Order.groovy:2:5: error: unable to resolve class Custmer
The gain is not only volume. Under FULL the position is split across
two lines with the line number repeated and the column given only on the
second, and no severity token appears at all — so the format no common
tool parses is also the one that says least per line. SHORT reports the
position once, with the column, and labels each diagnostic error or
warning. Messages that span several lines are joined, so one line per
diagnostic holds even for the type-checking errors that embed newlines.
On one source producing 14 type-checking errors, FULL renders 73 lines
and SHORT renders 17.
Set it through CompilerConfiguration.errorFormat, on the command line
as groovyc --error-format short, or on the Ant task as
<groovyc errorFormat="short"> (accepted in any casing, since build
files are written by hand). Each message contributes what it has rather
than inventing a position: a message with no source unit drops the file
and position instead of fabricating them, and an ExceptionMessage drops
the stack trace, which -de still asks for. Message subclasses outside
the Groovy tree keep compiling and render the full form, which degrades
sensibly.
Together with --check (see Other Core API
Changes), this gives tooling a fast, parseable compile: check without
writing class files, and report what failed in one line each.
Clearer Syntax Error Messages
Groovy 6 continues the work begun on unbalanced delimiters
(GROOVY-12169,
GROOVY-12171) and gives
the rest of the everyday mistakes a message that names what is wrong, with a
caret on the token that caused it, in the shape javac uses
(GROOVY-12353).
An unterminated string or comment is reported where it opened, rather than at whatever the lexer eventually tripped over:
Unclosed string literal @ line 1, column 9.
println 'Hello
^
A closed literal carrying a backslash that is not a Groovy escape — the Windows path everyone writes at least once — is the illegal escape it actually is, not an unclosed quote, and the caret sits on the backslash:
Illegal escape character: '\U' @ line 1, column 8.
x = "C:\Users\me"
^
A character that cannot appear in source is now printed as an escape rather
than emitted raw, which is the difference between a message you can read and
a message with a hole in it. A zero-width space, a BOM, a no-break space, a
curly quote or an em dash pasted in from a document or a chat window each
name themselves — Unexpected character: '\u200b' — where previously the
message embedded the invisible character itself.
Missing punctuation is named rather than described as unexpected input:
Missing '(' for if true { x = 1 }, Missing ':' for an incomplete
ternary, Missing '>' for an unclosed type-argument list, and Missing ']'
for an unclosed index — including the safe-index form, where a?[0 reports
the bracket and ?[0] with nothing in front of it reports
'?[' requires an expression before it.
A word that is reserved but not supported says so, and offers the Groovy spelling where there is one:
'const' is not supported; use 'val' or 'static final' instead @ line 1, column 1.
const x = 1
^
goto and threadsafe report likewise, and a control-flow keyword in the
wrong place is named for what it is rather than for the parse it broke:
'else' without 'if', 'catch' without 'try', 'finally' without 'try',
'case' outside of switch, 'default' outside of switch. Lexical and
structural mistakes get the same treatment — Number ending with
underscores is invalid, Invalid octal number, Shebang comment should
appear at the first line, Unexpected end of input for a truncated
statement, and a var-arg parameter that is not last reported by name.
These compose with the rest of the diagnostics work: --error-format short
(see Compiler Diagnostic Output) renders each of them as one parseable line, and parser
error recovery collects several from a single parse rather than stopping at
the first.
Other Core API Changes
-
ASTTransformationCustomizernow supports annotations whose@GroovyASTTransformationClasslists multiple transforms (e.g.@Sealed,@RecordBase) and@AnnotationCollectoraliases (e.g.@AutoExternalize), via a newforAnnotation(…)static factory that returns one customizer per resolved transform class (GROOVY-11973). For example:config.addCompilationCustomizers(*ASTTransformationCustomizer.forAnnotation(Sealed)). -
The Groovy 5.0 change to
File/Pathtruthiness — whereby a non-existent file is falsy — can be reverted as a porting aid by setting-Dgroovy.truth.file.exists.enabled=false. With the flag set tofalse, any non-nullFileorPathreference is truthy (matching Groovy 4 behaviour); the default (true) preserves the Groovy 5 existence-aware semantics (GROOVY-11996). -
Tuple.equalsnow follows Groovy’sListequals semantics: aTuplecompares equal to anyListwith the same elements (element-wise, using Groovy equality), rather than requiring anotherTuple(GROOVY-12017). -
The
invokedynamicpolymorphic inline cache evicts GC-cleared entries inline on the calling thread, under the (bounded) cache’s existing lock. During development this release briefly used a backgroundPIC-Cleanerdaemon thread with agroovy.indy.callsite.cleaner.inlineopt-out (GROOVY-12092); the thread and its flag were removed again because a never-terminating daemon started from a static initializer pins the defining class loader in redeploy scenarios — inline sweeping is now the only mode (GROOVY-12142). -
Subscript increment/decrement and elvis-assignment no longer evaluate the receiver (and index) more than once:
a[i]++/a[i]--evaluate the receiver a single time (GROOVY-12098), anda[i] ?= vevaluates both the receiver and the index once (GROOVY-12099). Side effects in the receiver or index expression now occur exactly once. -
Internal, locale-independent case conversions now use
Locale.ROOTinstead of the default locale, so keyword, identifier and format handling no longer varies by platform locale (e.g. the Turkish dotless-i). Case conversion in your own code is unaffected (GROOVY-12055). -
Dynamic property writes now compile to
invokedynamiccall sites by default (wheninvokedynamicis in use). Thegroovy.indy.setpropertyflag flips from opt-in to opt-out; set-Dgroovy.indy.setproperty=falseto restore the previous staticScriptBytecodeAdapter.setPropertycodegen. Runtime behaviour is unchanged — this is a code-generation and performance change that brings property writes in line with property reads (GROOVY-12138). -
groovycnow prints collected warnings tostderrafter a successful compilation, making the long-documented-w/--warningLeveloption effective (previously collected warnings were shown only when compilation failed). This also applies to Ant’s<groovyc>in-process compiles; thegroovyscript runner is deliberately left unchanged (GROOVY-12132). -
Check-only compilation:
CompilerConfigurationgains atargetPhaseproperty giving the last phase to be processed whenCompilationUnit.compile()is called without an explicit phase (the default remains all phases). Setting an earlier phase such asPhases.INSTRUCTION_SELECTIONreports parse, resolution, and static type-checking errors without generating any class files. The newgroovyc --checkoption is shorthand for exactly that, giving a faster feedback loop for editors, CI checks, and coding agents (GROOVY-12204).--checkstops afterPhases.CLASS_GENERATIONrather than before it, so that the errors only class verification and bytecode generation can report — duplicate method signatures, illegal modifier combinations, abstract/final conflicts, direct field access diagnostics — are reported too. Bytecode is built in memory and discarded; theOUTPUTphase that would write the class files is what--checksuppresses. Selecting an earlier phase throughsetTargetPhase(or a compiler configuration script, which is applied after the option and so overrides it) remains available for a deliberately shallower check (GROOVY-12311). -
Error tolerance now covers type-checking errors. The configured tolerance (
groovyc -t,CompilerConfiguration.tolerance) was only enforced for errors reported throughSourceUnit.addError, so it had no effect on the most common error class in@CompileStaticcode: errors reported by visitors, including the static type checker, were unbounded however low the tolerance was set. Both routes are now tolerance-aware (the temporary collectors the type checker pushes for speculative checks still collect without bailing out). A tolerance of zero is treated as unlimited rather than fail-fast, and the<groovyc>Ant task gains a matchingtoleranceattribute for both its forked and in-process paths (GROOVY-12306). -
The Parrot parser gains an optional error-recovery mode: instead of aborting at the first syntax error it collects multiple diagnostics and returns a partial AST, improving the IDE/tooling experience. A partial module is only returned when the error collector actually holds diagnostics, so a failed parse can never surface as silent success. Enable it with
CompilerConfiguration.setErrorRecoveryEnabled(true)or-Dgroovy.parser.error.recovery=true(GROOVY-9192). -
Compiler diagnostics for unbalanced delimiters are clearer: missing
),]and}now produce more descriptive messages with accurate caret positions (GROOVY-12169), and a$that is not followed by a valid interpolation inside aGStringgives a targeted error rather than a generic one (GROOVY-12171). -
Bytecode generation now runs a peephole optimization pass that rewrites short, local instruction sequences into cheaper equivalents, producing smaller and slightly faster bytecode with no behavioural change (GROOVY-12065, GROOVY-12162).
-
Exception-handling bytecode is slimmer: a
try/catchwithout afinallyblock no longer emits a synthetic identity catch-all handler, shrinking exception tables and generated code with no behavioural change (GROOVY-12161). -
Experimental (GEP-27): statically-compiled lambdas and eligible closures can be hoisted to a method on the enclosing class instead of generating a per-instance class, reducing class count and allocation. Lambda hoisting is opt-in via
-Dgroovy.target.lambda.hoist=true(GROOVY-12143); closure packing is opt-in via@PackedClosuresor-Dgroovy.target.closure.pack=trueunder@CompileStatic(GROOVY-12151). Both default off; ineligible or escaping closures are unchanged, and the value stays a realgroovy.lang.Closure. Adding-Dgroovy.target.closure.pack.report=truereports each closure that is declined for packing, with the reason. -
Reproducible builds: several code-generation paths that previously emitted members in JVM hash order — which varies across runs and platforms — now emit them in a deterministic, source-encounter order. This covers method selection during static compilation (GROOVY-12156), covariant bridge methods (GROOVY-12157),
@AutoImplementmethods (GROOVY-12158), synthetic class-literal fields and accessors (GROOVY-12159),@AnnotationCollectormembers (GROOVY-12160) and annotation member ordering generally (GROOVY-12146, GROOVY-12149), so identical sources now yield byte-identical class files. -
Closure deserialization is hardened against a crafted owner/delegate reference cycle that could otherwise cause unbounded recursion (GROOVY-12109).
-
Type-use (
ElementType.TYPE_USE) annotations are now ingested when reading already compiled Java or Groovy classes, where they reside in type-annotation bytecode attributes rather than on member declarations. Annotation-driven tooling sees the same annotations whether a class comes from source or from a jar — in particular, theNullCheckertype checking extension now recognises JSpecify-style nullability annotations in compiled dependencies on the classpath (GROOVY-12206). The same applies at package level:package-info.classfiles of compiled dependencies are consulted during resolution, so a JSpecify-style@NullMarkedpackage declaration in a jar takes effect (GROOVY-12207). -
Runtime-generated helper classes — map/interface proxies, reflection dispatch helpers and per-class meta-method artifacts — are now defined as JEP 371 hidden classes where possible, via the new
org.apache.groovy.util.HiddenClassDefiner. Hidden classes are not discoverable by name, can share private access with their host class as nestmates (honouring module boundaries), and can be unloaded independently of the defining class loader, reducing metaspace pressure in long-running applications. Environments that restrict runtime class definition fall back transparently to the classicdefineClasspath (GROOVY-12223). -
Embedders that compile source they do not control can now disable
@ASTTestwith-Dgroovy.asttest.enable=false. The annotation’s closure executes during compilation, so merely compiling untrusted source would otherwise run it; the switch mirrorsgroovy.grape.enablefor@Grab(GROOVY-12236). -
SecureASTCustomizernow applies its restrictions beyond the script body and method bodies: constructors, static and instance initializer blocks, and field initializers are checked (GROOVY-12238), as is authored code that a transformation relocates into a generated or synthetic member — such as@TupleConstructor(pre=…)blocks and the@ConditionalInterruptcondition (GROOVY-12244). Purely generated code (accessors, delegate forwarders, enum machinery, trait bridges) remains exempt. Import rules now also cover a method pointer or method reference’s target type (ProcessBuilder.&new,ProcessBuilder::new), casts and coercions that construct an instance from a list, map or closure literal ((Foo) [..],[..] as Foo,(Runnable) { }), and named-argument subscript construction (Foo[a: 1]) (GROOVY-12279, GROOVY-12283). -
Scoped
invokedynamicinvalidation: metaclass changes no longer deoptimize everyinvokedynamiccall site in the JVM. Previously a single process-wideSwitchPointguarded all indy call sites, so any metaclass mutation — oneFoo.metaClass.bar = { … }, one registry update — forced every linked call site in the application to re-link. Call sites now guard on aSwitchPointowned by the receiver’s metaclass, so a change to one class leaves hot call sites on unrelated classes linked and inlined. Process-wide retirement is kept only where selection can depend on state outside the affected class: category enter/leave (use { }andVMPlugin.invalidateCallSites()), non-stock customMetaClassimplementations, and registry events that carry no class attribution. Dispatch semantics are unchanged; this is a performance change, and it is most visible in applications that mutate metaclasses at runtime, such as Grails, or during test suites that install and remove metaclasses between tests. Setting-Dgroovy.indy.invalidation.stats=truelogs invalidation counts (exact-class, category, and bulk) atFINElevel for diagnosis. The internalIndyInterface.switchPointfield, which held the old sharedSwitchPoint, has been removed (GROOVY-12191). A follow-up makes category enter/leave and other bulk invalidation proportional to the number of live call-site domains rather than to all loaded classes (GROOVY-12259). -
Further dynamic-dispatch tuning: cached
ClosuredoCalltargets are now invoked throughMethodHandle`s, with high-arity targets cached as argument-spreading handles (GROOVY-12263), and the argument-class guards on `invokedynamiccall sites gain fixed-arity forms for arities 1-4, avoiding the argument-array collection previously done on every guard check (GROOVY-12284). -
Reflection-free MOP dispatch: once a
CachedMethodhas been invoked often enough (groovy.cachedmethod.invoker.threshold, default 1000, aligned withgroovy.indy.optimize.threshold),CachedMethod.invokeinstalls a generated hidden nestmate thatINVOKE`s the target directly — or `invokeExact`s a `classDataMethodHandle— instead of callingMethod.invoke. The JIT then sees a constant target where it previously saw a reflective call. This is the MOP path only:invokedynamickeeps its call-siteLookupand is not fed the trampoline. Set-Dgroovy.cachedmethod.invoker.disable=trueto stay onMethod.invoke(GROOVY-12325). -
Reflective cold dispatch tier off by default on the JVM: the experimental tier that dispatches a plain method call through
Method.invokeuntil its call site has been hitgroovy.indy.optimize.thresholdtimes (GROOVY-12137) is now used only by AOT-linked call sites (GraalVM native images, where it is the steady state, or-Dgroovy.indy.aot.link=true). While a site is on the tier, reflection and Groovy runtime frames sit between the caller and the target, so logging frameworks (%C/%M/%L, the JUL source class and method),StackWalkerand stack traces report the runtime as the caller until the site promotes — forever for a rarely hitwarnorerror. Groovy 5 reported the caller correctly from the first call, so on a JVM the tier is now opt-in via-Dgroovy.indy.cold.reflection=true. 6.0.0-RC-1 shipped with the tier on by default: RC-1 users can pass-Dgroovy.indy.cold.reflection=falseas a JVM argument to get the 5.x behaviour back. Calls the runtime dispatches through the metaclass, such as a dynamic method name (log."$level"(msg)) or an explicitinvokeMethod, show runtime frames on every Groovy version; the invokedynamic guide documents the JUL and Logback settings that cover those, on a JVM and in native images (GROOVY-12354). Log4j2 has no equivalent setting, so@Log4j2gains astaticLocationoption that supplies the location from the compiler instead of leaving it to a stack walk — see Platform Logging. -
deleteDirtreats a junction as a leaf. Since GROOVY-12125 a symbolic link inside a tree being deleted is removed as the link rather than entered, but the guard wasFiles.isSymbolicLink, which does not report a Windows directory junction — the link form that matters most there, sincemklink /Jneeds no privilege where a Windows symbolic link needs one most users do not hold.File.deleteDirand thegroovy-nioPath.deleteDirnow read each node’s attributes without following links and treat anything that is neither a regular file nor a directory as a leaf, so a junction or other reparse point, a pipe, a socket or a device is removed as the node it is and never entered. On POSIX the visible behaviour is unchanged (GROOVY-12359). -
The
groovy.ast=xmldebug option writes a serialized AST to<source>.xmlbeside the source. It was created at the process umask, so a source the developer had kept private produced a world-readable dump of its AST. The dump is now created with the source file’s own permissions, and owner-only where there is no source file to match — created with the permissions rather than created and then adjusted, so its contents are never briefly readable by anyone those permissions exclude (GROOVY-12368). -
A bounded parser DFA cache. The shared ANTLR DFA cache had no ceiling out of the box, and the GC canary that dropped it is only observed on the parse path, so a long-lived daemon parsing across many modules could grow it unchecked between observations — surfacing as time spent collecting rather than parsing, or as an
OutOfMemoryErrorat a tight heap.groovy.antlr4.cache.sizenow bounds the DFA states retained across the shared ATN, defaulting to 20000; exceeding it drops the cache. Bounding by size costs nothing while the cache is small, so a project that never reaches the limit keeps a fully warm cache; the older parse-countgroovy.antlr4.cache.thresholdreverts to opt-in (GROOVY-12318). The threshold and the GC canary also compose rather than excluding each other: setting a positive threshold previously switched the canary off, so reaching for the documented knob to reduce memory use silently removed the one mechanism that responds to actual memory pressure. A negative value remains the explicit "never clear" escape hatch, now switching off both (GROOVY-12316). -
Field-backed property access is consistent across compilation modes. A non-public field of a strongly encapsulated class — a JDK class without
--add-opens, say — could be selected as a meta property and then fail only on read or write, with theinvokedynamicproperty path escalating the refusal toGroovyBugErrorand attribute access leaking a rawIllegalAccessException. A field whose access cannot be established is now treated as absent during selection, so the normal missing-member handling applies (MissingPropertyException/MissingFieldException, as in Groovy 4) and the indy selectors degrade to the genericMetaPropertyor adapter path. Fields whose access can be forced (open modules, class-path classes,--add-opens) are unaffected, and the check is deliberately not part of member selection, so a call site with its own access rights can still reach such a field.CachedFieldalso caches its deep-reflection handles and drops the synchronization around them (GROOVY-12314). -
Compiler-referenced ABI marker: the new
@org.apache.groovy.lang.annotation.GroovyABIannotation marks the internals that compiled Groovy bytecode links against directly — call sites,ScriptBytecodeAdapter, indy bootstraps, transform helpers. It does not make an element public API; it records that compiled Groovy may outlive the compiler version that produced it, so removing or changing a marked element is a break for already-compiled code. A mandatorysincegives the release in which the element joined the ABI, andCLASSretention keeps the marker in published jars so the build’s binary compatibility tooling can gate the annotated subset (GROOVY-12256). -
ObjectUtilis restored as a binary-compatibility facade. It was removed in Groovy 5, which broke Groovy 4-compiled@Immutableclasses at runtime; such classes now load and run again (GROOVY-12257). -
Compiler performance: several hot spots in the compiler itself were tuned — the static type checker indexes extension methods by name and no longer clones non-generic method parameters (GROOVY-12285), class generation avoids a full bytecode re-parse when completing
NestMembersattributes (GROOVY-12211) and streamlines its scan for unrelated default methods (GROOVY-12264), and the supertype lookupsClassWriterperforms when computing stack-map frames are cached per generated class (GROOVY-12288). These reduce compilation time and have no effect on the bytecode the compiler emits. -
Opt-in soft
GroovyClassValuemode: running with-Dgroovy.use.classvalue=softkeeps thejava.lang.ClassValueper-Classfast path for dynamic dispatch but stores each association through aSoftReference, cutting the only strong chain from immortal platform classes to Groovy’s class loader (JDK-8136353) — the mechanism behind metaspace growth on repeated redeploys (GROOVY-12142). Class metadata carrying non-reconstructible state — an installed or per-instance metaclass, registry-written MOP state — is strong-rooted so nothing observable is lost when memory pressure clears an entry; cleared entries are rebuilt on next use with call-site guard soundness preserved for bothinvokedynamicand legacy classic call sites. The default behaviour is unchanged, and the fullyClassValue-free fallback (groovy.use.classvalue=false) — an escape hatch Groovy 5.0 had dropped, restored in this release — is again available, now with its dispatch cost quantified in the integration guide (GROOVY-12281).
Other Module Changes
For the groovy-xml module changes (secure-by-default XML processing,
StAX streaming helpers, SerializeOptions, and named-parameter
construction), see XML Processing Improvements.
-
groovy-sql: The
DataSetclass now correctly handles non-literal expressions in queries (GROOVY-5373). -
groovy-sql: New
Sql.inListhelper expands a collection into the parameter list of a SQLINclause, avoiding manual string concatenation (GROOVY-5436). -
groovy-sql: Added Map-based named-parameter overloads for
call,callWithRows, andcallWithAllRows, matching the existing named-parameter support oneachRow,rows, etc. (GROOVY-11936). -
groovy-ant: The
<groovy>Ant task now inherits the surrounding project’s Ant properties when running in forked mode, matching the behaviour of non-forked execution (GROOVY-6908). -
groovy-ant: The
<groovyc>task gains nested<jvmarg>,<sysproperty>, and<syspropertyset>elements (mirroring Ant’s<java>task) that are passed to the spawned compiler JVM whenfork="true", plus aninheritAllattribute to pass all parent- project properties as system properties at once. Useful for compile-time configuration consumed by AST transforms, the parser/lexer, or annotation processors — for example<jvmarg value="--add-opens=java.base/java.lang=ALL-UNNAMED"/>or<sysproperty key="groovy.val.enabled" value="false"/>. Explicit<sysproperty>/<syspropertyset>entries take precedence on name collision withinheritAll(GROOVY-11995). -
groovy-jmx: Removed dead IIOP support and refreshed documentation (GROOVY-11921).
-
groovy-servlet: Jakarta EE 11 compatibility (GROOVY-11922).
-
groovy-json:
JsonSlurpernow enforces a maximum nesting depth (default 1000) when parsing, guarding against stack exhaustion from maliciously deep documents. The limit is configurable per parser viasetMaxNestingDepth(int)(a value⇐ 0disables the check) or globally with thegroovy.json.maxNestingDepthsystem property (GROOVY-12064). -
groovy-json: integer values exceeding the
longrange now parse toBigInteger, preserving full precision instead of overflowing; values withinlongrange are unchanged (GROOVY-12101). -
groovy-json:
JsonSlurperalso bounds the length of a number token (default 1000 characters, matching Jackson’sStreamReadConstraints, so JSON is bounded consistently with the Jackson-backed YAML/TOML/CSV slurpers). Conversion of a long digit run throughBigInteger/BigDecimalis superlinear, so an unbounded token made a few hundred KB of input cost seconds of CPU. Configure it per instance withsetMaxNumberLength(int)or globally withgroovy.json.maxNumberLength; a value⇐ 0disables the check. See Breaking changes (GROOVY-12329). -
groovy-json:
JsonSlurper.setDateHandling(JsonDateHandling)says which type a date-like string becomes, rather than only whether to convert one. The four choices areSTRING,UTIL_DATE(the default, and what a slurper has always produced),INSTANTandOFFSET_DATE_TIME— the only one that keeps the offset the document carried, since aDateand anInstantare both points on the timeline. As before, this applies to theINDEX_OVERLAYandLAXparser types; the other two return the string whichever handling is chosen, and a date carrying no time ("2026-09-04") is left as aStringby all four. The booleansetCheckDates/isCheckDatespair remains, mapping ontoUTIL_DATEandSTRING, but is deprecated and no longer has any effect oncesetDateHandlinghas been called — in either order, so a choice made through the wider option is not undone by the narrower one (GROOVY-12352). -
groovy-xml:
XmlParserandXmlSlurpernow bound element nesting depth at 1000 by default, matchinggroovy-json. Secure processing does not bound depth, and the JAXP limit that does (jdk.xml.maxElementDepth) defaults to unlimited. The SAX parse itself survives deep input — the damage lands on the first consumer to walk the result recursively (Node.text(),XmlNodePrinter,GPathResult.toString(),XmlUtil.serialize), each of which runs a stack frame per level, so a 350KB document 50,000 elements deep parsed cleanly and then killed all of them with aStackOverflowError— anError, and so outside thecatch (Exception)an application would reasonably use for a malformed document. The JDK now enforces the bound during the parse and names the offending element. Nothing is applied whenjdk.xml.maxElementDepthhas already been set explicitly (GROOVY-12331). -
groovy-json:
JsonOutput.prettyPrintgains an overload taking the nesting bound, and bounds the depth it will format by default (GROOVY-12330). -
ConfigObject.writeTonow writes keys and values as data rather than as source.ConfigSlurper.parsecompiles whatwriteToproduced, but keys were written bare unless they were Groovy keywords and values were rendered byFormatHelper.inspect, which quotes aStringbut not other types. The documented round trip therefore failed for keys containing spaces or quotes, for nested blocks under such keys, forGString,StringBuilderand most non-Stringvalues — and a key holding a statement was executed on re-parse, so an application persisting a configuration with an attacker-influenced entry name would run it. Every key is now rendered as an identifier when it is one and as a quoted literal otherwise, and values are written as data (GROOVY-12273). -
The YAML, TOML, CSV and Markdown stream parsers now decode
InputStreaminput as UTF-8 rather than the platform default charset, for consistent cross-environment results; pass aReaderto use a different encoding (GROOVY-12074). -
groovy-jmx:
JmxBuildernow passes the connector environment map through to the JMX connector server/client rather than discarding it, so connector-specific options are honoured (GROOVY-12119). -
groovy-jmx:
JmxBuilder’s documented connector authentication properties (`authenticate,passwordFile,accessFile, and the newloginConfigfor JAAS) are now mapped onto the standardjmx.remote.x.keys that connectors actually consume, so authentication configured throughJmxBuilderis enforced rather than silently ignored. Requestingauthenticate: truewithout any source of credentials is now rejected rather than starting an open connector. The legacycom.sun.management.jmxremote.spellings remain accepted as input but are no longer copied into the connector environment, where they had no effect (GROOVY-12270). -
groovy-datetime:
TimeCategorygains two modern flavours — ajava.time-based category producingDuration/Periodvalues for the modern date/time API, and a "dequirked"java.util.Datecategory that keeps the familiar DSL while smoothing over the legacyDate/Calendarrough edges. The originalgroovy.time.TimeCategoryremains for backwards compatibility (GROOVY-12124). -
groovy-sql:
Sql’s prepared-statement cache is now bounded and thread-safe. It was an unbounded `HashMapkeyed on the SQL text and mutated without synchronization, so it grew without limit whenever the text varied rather than the parameters —inListexpands to a different placeholder count per list size, so a caller sizing the list minted a distinct cached statement each time, held open until theSqlwas closed — and a sharedSqlused from more than one thread could corrupt the map. It is now a synchronized, access-ordered cache that evicts the least recently used statement past a cap and closes it on the way out, so bounding it does not leak the cursor it drops. The cap isstatementCacheSize, default 256, also settable withgroovy.sql.statement.cache.size;0or less keeps the old unbounded behaviour. Statement creation stays outside the lock, so preparing one does not serialize other callers. Where a singleSqlreally is shared across threads, size the cache above the working set: a bound below it can now evict and close a statement another thread was about to reuse (GROOVY-12371). -
groovy-servlet: GroovyServlet no longer sends error detail to the client. A failing groovlet put the script path, the exception message and the top stack frame in the 500 response body — the same detail already written to the servlet and application logs — which discloses internals to callers and, since the response is
text/htmlcarrying a request-derived script path, is a reflected-content risk where the container does not escape thesendErrormessage. The client now gets a generic 500 and the full detail still goes to the logs. A protectedisVerboseErrors()hook, defaulting to thegroovy.servlet.verbose.errorssystem property, restores the detailed responses for debugging (GROOVY-12373). -
groovy-swing: LookAndFeelHelper's Metal
themeattribute treated an unrecognised string as a class name and resolved, initialised and instantiated it before discovering whether it was even aMetalTheme— the cast failed only afterwards — and resolved through its own class loader, so an application- or@Grab-supplied theme was not found at all. The class is now resolved without initialising it, through the thread context class loader, and confirmed to be aMetalThemebefore construction; anything else raisesIllegalArgumentExceptionwithout being initialised (GROOVY-12376).
Breaking changes
Removal of Security Manager support
Java’s Security Manager has been deprecated for removal by JEP 411, which argues that it is rarely used to secure modern applications and that security is better achieved through other mechanisms such as containers and operating system security.
Groovy 6 removes its use of AccessController.doPrivileged calls
and related Security Manager infrastructure
(GROOVY-10581).
Code that relied on Groovy’s Security Manager integration
should adopt alternative security mechanisms.
Groovy 5 still includes such support on JDK versions that support it.
Other changes
-
The command-line launchers no longer add the current directory to a classpath you supplied.
groovy,groovyc,groovyshand friends appended.to whatever came from-cp/-classpathor theCLASSPATHenvironment variable, which thejavalauncher does not do. An explicit classpath is now honoured as-is; only a bare invocation with no classpath given falls back to the current directory. If you relied on the implicit.— running a script that loads a class from the working directory while also passing-cpfor a library jar — add.to the classpath yourself, e.g.-cp lib.jar:.(-cp lib.jar;.on Windows) (GROOVY-12374). -
String-to-
Classcoercion no longer runs the class’s static initializer.'com.example.Foo' as Class, and the(Class)cast the compiler routes throughShortTypeHandling.castToClass, now resolve the named class without initializing it:Class.forName(name, false, loader)in place of the single-argumentClass.forName(name), with the same class loader as before. The class is still initialized lazily on first real use, as the JVM always does. Code that relied on the coercion for the side effect — most commonly the legacy JDBC idiom'com.mysql.jdbc.Driver' as Classto register a driver — should callClass.forName(name)explicitly; modern JDBC drivers auto-register throughServiceLoaderand need none of it. The signature is unchanged, so this is a behavioural change only (GROOVY-12375). -
decodeBase64rejects malformed padding. The decoder set a done flag on=but never checked that the padding filled its group, so it accepted a single=where two were needed (YQ=), an extra=past a full group (YQ===), and padding on an already complete group (YWJj=) — each decoding as if the padding were correct, so distinct malformed strings mapped to the same bytes. Padding is now valid only when it fills its four-character group exactly and the group already holds at least two data characters; any other shape throws. Deliberately unchanged: missing padding still decodes (YQandYWJjboth work, as withjava.util.Base64), whitespace and the newlines of a chunked encoding are still ignored soencodeBase64(chunked: true)round-trips, and non-canonical trailing bits are still accepted (GROOVY-12372). -
GroovyServletno longer includes the script path, exception message and top stack frame in the 500 response body; the client gets a generic error and the detail goes to the logs. Set-Dgroovy.servlet.verbose.errors=true(or overrideisVerboseErrors()) to restore the detailed responses for debugging. See Other Module Changes (GROOVY-12373). -
valis now a contextual keyword for declaring final variables (seevalKeyword for Final Declarations). A few pre-existing edge cases around the siblingvarkeyword now apply tovalas well — chiefly a field namedvaldeclared immediately before a method or constructor, andval as Typecast expressions. The-Dgroovy.val.enabled=falsesystem property reverts to the prior behaviour as a porting aid. (GROOVY-9308, GROOVY-11994) -
Annotation target validation is now enforced for import and loop statements (see Improved Annotation Validation). (GROOVY-11884)
-
The inner class
methodMissingandpropertyMissingprotocol was redesigned. Some scenarios that previously allowed access to an outer class’s members through an inner class instance (e.g. accessing outer fields via an object expression, outer classinvokeMethod/MOP overloads from anonymous inner classes) may no longer work. (GROOVY-11853) -
When multiple
set(String, …)method overloads exist, Groovy now selects the best-matching overload based on the value type rather than always using the same method. This fixes incorrectGroovyCastExceptionerrors at runtime but may change which setter is invoked if your code relied on the previous behaviour. (GROOVY-11829) -
TomlSlurperandYamlSlurperno longer prematurely closeReaderandInputStreamarguments passed to parsing methods. Callers are now responsible for closing resources they create, following standard conventions. (GROOVY-11925, GROOVY-11926) -
XmlParser.parse(File)andXmlParser.parse(Path)now properly close the underlyingInputStreamafter parsing (see XML Processing Improvements). Previously, the stream was not closed, which could cause file descriptor leaks. (GROOVY-11927) -
XmlParser.setNamespaceAware(boolean)now throwsIllegalStateExceptionif called after parsing has started (see XML Processing Improvements). Previously, the setter silently updated the field but had no effect since the SAX parser was already configured. (GROOVY-7633) -
groovy-xml hardening: three default-behaviour changes to behind-the-scenes XML factory creation paths — see XML Processing Improvements for the security rationale and per-API details. The front-line parsers (
XmlParser,XmlSlurper, the staticDOMBuilder.parse(…)overloads,XmlUtil.newSAXParser) were already secure-by-default and are unaffected. Migration knobs: (1)SerializeOptions.allowExternalResources = truere-enables external XSL imports/includes and external DTD references inXmlUtil.serialize. (2) Direct callers of the zero-argFactorySupport.createDocumentBuilderFactory()/createSaxParserFactory()parsing DOCTYPE-bearing input should switch to the new(true)overload. (3) Code that reaches intodomBuilder.documentBuilderand parses DOCTYPE-bearing XML directly should use the new three-argDOMBuilder.newInstance(validating, namespaceAware, allowDocTypeDeclaration). (GROOVY-11981) -
The last references to
javax.swing.JApplethave been removed fromgroovy-swing(theSwingBuilderfactory registration) and from the Groovy Console.JAppletwas deprecated for removal since Java 9 and has been removed in JDK 26, so these references would otherwise causeNoClassDefFoundErroratSwingBuilderinitialisation on JDK 26+. Code that explicitly used the applet-related factories should migrate to standardJFrame/JWindowalternatives. (GROOVY-11912) -
StringGroovyMethods#stripIndent(boolean)now handles non-Stringarguments as documented. Previously certain non-StringCharSequenceinputs were not stripped as advertised; affected code now produces the documented (stripped) result. (GROOVY-12009) -
IntRange.containsWithinBoundsno longer delegates tocontains, restoring the continuous-bounds contract (the bounds check ignores any non-unit step). Code that relied on the previous step-aware delegation may see different results. (GROOVY-12067) -
ClassNodeUtils.getPropNameForAccessornow uses JavaBeans decapitalization, matching runtime property naming (e.g. an accessorgetURLmaps to propertyURL, notuRL). This corrects property-name derivation in AST transforms and type checking but may change derived names in code that relied on the prior behaviour. (GROOVY-12058) -
SourceTextnow slices source using UTF-16 offsets aligned with the code-point columns carried by the AST, so retrieved source containing supplementary characters (e.g. emoji, some CJK) is no longer truncated. Tooling that compensated for the previous truncation may need adjustment. (GROOVY-12085) -
Map membership via the
inoperator (andisCase) is now key-based:key in maptestsmap.containsKey(key)rather than the former value-truthy semantics, which could even mutate awithDefaultmap by materialising the queried key. Code that tested a map’s values should usemap.containsValue(v)(orv in map.values()). (GROOVY-9848) -
Under static compilation,
withDefaulton a map whose key/value types are statically known now binds the type-checked variant (via@ClassTagpreemption): the wrapped map throwsClassCastExceptionfor ill-typed keys and values — including a wrong-typed key materialised by aget— instead of silently violating the map’s declared generics. Set-Dgroovy.classtag.preemption.disable=trueto defer the upgrade (porting aid — see Compiler-Supplied Class Tokens (incubating)). Dynamic code is unaffected. (GROOVY-12115, GROOVY-11807) -
Plain subscript assignment evaluates strictly left-to-right again: in
a[index] = expr, the receiver andindexare evaluated beforeexpr. A long-standing regression (from GROOVY-2556) had causedexprto be evaluated first. Code whoseindexor right-hand side has side effects that relied on the previous order may behave differently. (GROOVY-12097) -
groovy-sql: a GString query containing a quoted dynamic expression (an interpolation placed inside quotes, which cannot be bound as a
PreparedStatementplaceholder and is a SQL-injection risk, CWE-89) is now rejected with aSQLExceptionrather than only warned about. Set-Dgroovy.sql.injection.lenient=trueto restore the previous (less secure) behaviour as a porting aid. The same pattern can be caught earlier, at compile time, with the SqlInjectionChecker type-checking extension (GROOVY-12187). (GROOVY-12118) -
A
for-in (enhanced for-each) loop variable captured by a deferred closure, lambda or anonymous inner class now uses a fresh binding per iteration, so the capture observes the value from the iteration that created it (as Java does for enhanced-for/lambda capture) rather than the loop’s final value. Assignments to the loop variable within the same iteration remain visible to captures made in that iteration; classicfor/whileloops are unchanged. To restore the historical single shared binding, set-Dgroovy.forin.per.iteration.capture=falseor callCompilerConfiguration.setForInPerIterationCaptureEnabled(false)(GROOVY-11792). -
Enum constants that supply no arguments of their own are now created with a direct call to the enum’s
(String,int)constructor — the bytecode shapejavacemits — so creating them no longer needs reflection over the enum’s constructors at class initialization. Such enums now initialize in environments where that reflection must be declared ahead of time: GraalVM native images without reachability metadata for the enum, or shrunken Android applications without keep rules for the enum’s constructor. Consequently the creation of those constants no longer dispatches through the enum’s meta class: a meta class registered before the enum initializes no longer sees the constants being created. Declaring a constant with an explicit empty argument list, e.g.ONE(), supplies the same name and ordinal through the previous meta-class-mediated path and restores interceptability for that enum. This is a compile-time change: enums compiled by earlier Groovy versions keep their existing behaviour whichever Groovy runs them (GROOVY-12240). -
instanceof(and!instanceof) pattern variables now follow Java’s flow scoping rules (seeinstanceofpattern variable flow scoping (JEP 394)). In dynamic code, reading a pattern variable where flow scoping says it is not bound — for example in the else-block of a plaininstanceoftest, after the if-statement, or on the right of||— is now a dynamic property lookup, typically failing at runtime withMissingPropertyException, where it previously read the leaked variable. Under@TypeChecked/@CompileStaticsuch reads are compile-time errors. Declaring a new variable with the pattern variable’s name where it is not bound is now allowed (GROOVY-12242). -
Switch expressions are now compiled as first-class AST (
SwitchExpressionandYieldStatementnodes) instead of being desugared into an immediately-called closure wrapping a switch statement, eliminating the per-evaluation closure allocation and the synthetic closure class. Two behavioural consequences: a dynamic switch expression whose selector matches no arm now throwsIllegalStateException, where the desugared form silently evaluated tonull; and under@TypeChecked/@CompileStatica non-exhaustive switch expression (nodefaultarm and not covering a complete enum) is now a compile-time error. Matching semantics are unchanged — arms still use Groovy’sisCase(class, regex, collection, closure) — andtableswitch/lookupswitchdispatch is emitted when the selector and labels are constants Java could switch on. AST transforms and visitors that assumed the old closure-call shape need to handle the new nodes;GroovyCodeVisitorsupplies default methods so existing visitors keep compiling (GROOVY-12255). -
Method-level type-checking annotations now override a class-level
SKIP: a method (or constructor) whose own@TypeCheckedor@CompileStaticannotation has the default non-SKIPmode is type checked — and for@CompileStatic, statically compiled — even when its declaring class is annotated with@CompileDynamic,@CompileStatic(TypeCheckingMode.SKIP)or@TypeChecked(TypeCheckingMode.SKIP). Previously the class-levelSKIPsilently won and the method-level annotation was ignored; such methods may now raise type-checking errors that previously went unreported, and their bodies are statically compiled where they were previously dynamic. Remove the method-level annotation (or give itSKIPmode) to restore the old behaviour. Nested classes already behaved this way: the most specific annotation wins, and a class-levelSKIPis the default only for members without their own annotation. Unchanged: method-level@CompileStatic(TypeCheckingMode.SKIP)/@CompileDynamicstill disables static compilation only, without exempting the method from an enclosing class’s@TypeCheckedchecking (GROOVY-12292). -
The
CompilerConfigurationcopy constructor now copies compilation customizers (and type-checking extension configuration), so a configuration cloned from a base config behaves like the original. Code that worked around the omission by re-adding customizers after copying should drop the workaround, or the customizers will be applied twice (GROOVY-9585). -
The classic (non-
invokedynamic) call-site caching runtime has moved from core into the optionalgroovy-callsitemodule (see New Modules). Applications that run withinvokedynamicdisabled must add that module to their classpath; the defaultinvokedynamicruntime needs nothing extra (GROOVY-12185). -
As part of scoped
invokedynamicinvalidation (see Other Core API Changes), the internalIndyInterface.switchPointfield, which held the former process-wideSwitchPoint, has been removed. Tooling that reached into that field must be updated; dispatch semantics are unchanged (GROOVY-12191). -
Generic types must now be well-formed by the JLS rules Java applies, in every compilation unit — dynamic Groovy and scripts included (see Generic type syntax). Constructs that Groovy 4 and 5 accepted by silently falling back to the erasure are compile errors: generic array creation (
new T[n],new List<String>[n]), a generic class extendingThrowable, a wildcard in a class instance creation or superinterface, a primitive type argument (List<int>), instantiating or naming a type variable (new T(),T.class), a parameterized class literal (Foo<String>.class), a non-first class bound in<T extends A & B>, a parameterized non-static member of a raw type, and catching a type parameter.new List<?>[n]remains legal — an unbounded wildcard is reifiable. Two placeholders namedTfrom different declarations are now distinguished, so a method’sTcan no longer be stored in a field typed by the class’sT, and duplicate names in one type-parameter section are rejected. The Generics section of the object orientation guide tabulates each rejected construct with its workaround (GROOVY-12319). -
Field-backed property access is now consistent across compilation modes: a field whose access cannot be established — a non-public field of a strongly encapsulated class, with no
--add-opens— is treated as absent during member selection rather than being selected and then failing on use. Such an access now raisesMissingPropertyException/MissingFieldException(as in Groovy 4) where theinvokedynamicproperty path previously escalated toGroovyBugErrorand attribute access leaked a rawIllegalAccessException. Fields whose access can be forced are unaffected (GROOVY-12314). -
groovy-json:
JsonSlurperrejects a number token longer than 1000 characters with aJsonException, where any length previously parsed. Raise the bound withsetMaxNumberLength(int)or-Dgroovy.json.maxNumberLength=<n>, or disable the check with a value⇐ 0, if you legitimately carry such values (GROOVY-12329). -
groovy-json: date handling on
JsonSlurpermoves from a boolean to theJsonDateHandlingenum (see Other Module Changes). The default is unchanged —UTIL_DATE, ajava.util.Date, as before — butsetCheckDates/isCheckDatesare deprecated, and oncesetDateHandlinghas been called on a slurper the boolean has no further effect on it. In the internalorg.apache.groovy.json.internalpackage, the four-argumentJsonParserLaxandJsonFastParserconstructors now take aJsonDateHandlingin place of theboolean; the deprecatedbooleanform of theCharSequenceValueconstructor is retained (GROOVY-12352). -
groovy-xml:
StreamingSAXBuildernow applies the comment and processing-instruction validators its sibling builders already used, so a comment body containing--or ending in-, or a processing-instruction target or data containing?>, is rejected rather than passed to theContentHandler. Such input was never well-formed XML — the specification forbids all three — but it was previously accepted, and because neither context supports escaping it closed the construct early and emitted whatever followed as markup (GROOVY-12338). -
groovy-xml:
XmlParserandXmlSlurperbound element nesting depth at 1000 by default, so a document deeper than that is now refused by the parse that reads it. Setjdk.xml.maxElementDepthexplicitly to choose a different bound (or0for unlimited) — Groovy applies nothing when that property is already set (GROOVY-12331). -
ConfigObject.writeTonow emits keys and values as data rather than as source, so the output of a round trip throughConfigSlurper.parsediffers for keys that are not plain identifiers and for values that are notString. Code that parsedwriteTooutput with something other thanConfigSlurper, or that relied on a key being written bare, needs adjusting; the round trip itself now holds where it previously failed or, for a key holding a statement, executed (GROOVY-12273).
System Property Reference
Groovy 6 introduces or changes a number of runtime feature switches. Each
is a JVM system property, set on the command line with
-D<name>=<value> (or via System.setProperty before the relevant code
runs). This table collects the switches added or changed in this release;
it does not repeat the compiler options already listed in the
CompilerConfiguration
javadoc.
| Property | Default | Effect | Ticket |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
off |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
auto |
|
|
|
|
|
|
|
unset |
Reflective cold dispatch tier for plain method calls. Unset: used only
by AOT-linked call sites (native images). |
|
|
|
Selects the class-metadata store: |
|
|
|
Invocation count after which |
|
|
|
|
|
|
|
|
|
|
|
Ceiling on DFA states retained across the shared parser ATN; exceeding it
drops the cache. A value |
|
|
|
Parse count after which the shared parser DFA cache is cleared (opt-in;
|
|
|
|
Experimental (GEP-27): hoist eligible statically-compiled lambdas to a method on the enclosing class (see Other Core API Changes). |
|
|
|
Experimental (GEP-27): pack eligible closures under |
|
|
|
Diagnostic companion to closure packing: report each closure declined for packing, with the reason. |
|
|
|
|
|
|
|
Maximum nesting depth for |
|
|
|
Maximum length in characters of a JSON number token for |
|
|
|
Maximum nesting depth for |
|
|
native encoding |
Charset used to decode subprocess output streams ( |
|
|
|
Maximum threads in the async/await cached daemon pool (the JDK 17—20 fallback path used when virtual threads are unavailable). |
|
|
|
|
|
|
— |
Comma-separated GDK extension-method signatures to disable, e.g.
|
|
|
|
Maximum prepared statements |
|
|
|
|
|
Note
|
The SqlInjectionChecker compile-time check
(SqlInjectionChecker) is not a system property; it can be suppressed
at a call site with @SuppressWarnings("groovy.sql.injection").
|
Under exploration (before GA release)
-
Further performance improvements
-
Further improvements to the language specification documentation
JDK requirements
Groovy 6 requires JDK17+ to build and JDK17 is the minimum version of the JRE that we support. Groovy 6 has been tested on JDK versions 17 through 27.
CompilerConfiguration also recognises a JDK27 ("27")
targetBytecode value, mapped to the corresponding ASM V27 class-file
version, ahead of that JDK’s general release
(GROOVY-12028).
More information
You can browse all the tickets closed for Groovy 6.0 in JIRA.