groovy

Groovy everywhere, with one large asterisk

Groovy everywhere, with one large asterisk

I did not set out to learn Groovy. I set out to make a Jenkins pipeline do something, and a Jenkinsfile is Groovy, so I learned as much of it as the error messages demanded and no more. Six weeks later I had a shared library and a NotSerializableException I could not explain, which is the point at which "as much as the error messages demand" stops being a strategy.

Groovy 4.0.0 went GA on the twenty-fifth of January, which is a good excuse to write down what I worked out. Including the part where the Groovy in a Jenkinsfile is a dialect from 2015 running through a rewriting compiler inside a whitelist.

What it is

A JVM language, first released in 2003, an Apache project since 2015. groovyc compiles it to ordinary JVM bytecode — the .class files are indistinguishable from Java's, and Java code can call into them without knowing. Almost all valid Java is also valid Groovy, which is why the on-ramp feels so short.

The pitch is: everything Java does, minus the ceremony, plus closures, plus a metaobject protocol that lets you bend the language into a domain-specific one.

class Agent {
  String name
  List<String> labels = []
}

def win = new Agent(name: 'win-build-01', labels: ['windows', 'signing'])

assert win.labels.any { it.startsWith('win') }
assert win.labels                                   // a non-empty list is truthy

No public, no getters and setters you wrote, a map constructor you did not write, it as the implicit closure parameter, and Groovy truth. That is the entire appeal in six lines: it reads like the thing you meant.

How it runs

Three mechanics are worth understanding, because all three of them show up later as constraints.

A script is a class. Compile a file with bare statements at the top level and you get a class extending groovy.lang.Script, with your statements in a run() method:

$ echo 'println "hello"' > hi.groovy
$ groovyc hi.groovy && javap hi.class
public class hi extends groovy.lang.Script {
  public hi();
  public java.lang.Object run();
  public static void main(java.lang.String...);
}

Variables declared without def or a type do not become locals — they go into the script's Binding. That is exactly how Jenkins injects env, params and currentBuild into your Jenkinsfile, and how Gradle injects project into a build script. It is not magic; it is a Map the host filled in before calling run().

Method calls go through a metaclass. thing.doIt() does not compile to a direct virtual call on doIt. It goes to the object's MetaClass, which can be modified at runtime. That indirection is what buys you methodMissing, propertyMissing, category classes, and builders — it is why task hello { } in a Gradle file reads like syntax when it is a method call the project object handles dynamically.

Groovy 4 made that dispatch always go through invokedynamic; the separate non-indy build variants are gone. That, and the move to the org.apache.groovy group id, are the two changes most likely to affect you before any of the new syntax does.

You can turn the dynamism off. @CompileStatic compiles a class with Java-like static dispatch: real compile-time errors, performance close to Java, and none of the metaprogramming.

import groovy.transform.CompileStatic

@CompileStatic
long freeBytes(List<Map<String, Object>> disks) {
  disks.sum { (long) it.freeBytes } as long
  // it.freBytes is now a compile error rather than a null at two in the morning
}

The pattern that works is static by default in anything that behaves like a library, dynamic only where the DSL actually lives. Which, on reflection, is the same advice as "keep the clever part small".

Where it genuinely is everywhere

Here is the thing I did not appreciate until I looked: I have been running Groovy for years without writing any.

Gradle. Every build.gradle in the Java world, which is most of the Java world. On sheer executions this probably makes Groovy one of the most-run languages on Earth.

Jenkins. Jenkinsfiles, shared libraries, the script console, Job DSL, and the Groovy hooks in Configuration as Code.

Spock. The nicest test DSL on the JVM, and it is Groovy that makes given: / when: / then: / where: blocks possible rather than a comment convention. Spock 2.0 moved onto the JUnit 5 platform last year, so it now drops into an existing Java build without an argument.

Grails. Still shipping; version 5 landed in the autumn.

Anything with a scripting hook. Apache NiFi, SoapUI, Jira, Confluence, Bamboo, and a long tail of internal tools that needed "let the user write a bit of logic" and already had a JVM in the process.

The pattern is consistent, and it is not "people chose Groovy for their application". Groovy turns up as the configuration and glue layer over a system written in Java. It is the JVM's shell script. That is not a small role — it is a specific one, and being honest about it is more useful than the slogan.

The asterisk, and it is Jenkins

The Groovy in a Jenkinsfile is not the Groovy that shipped last month. Three separate reasons, and they compound.

It is Groovy 2.4. Jenkins core bundles a Groovy 2.4.x runtime and pipeline execution uses it. No Parrot parser, which means none of Groovy 3's syntax — no Java-style lambdas, no method references, no !in, no do/while. And obviously nothing from 4. Copy a snippet off a 2022 blog post that does agents.stream().map(Agent::getName) and it will not compile in your pipeline, with an error that does not mention versions.

It is CPS-transformed. This is the big one. A pipeline has to survive the controller restarting halfway through a build, so the Pipeline: Groovy plugin rewrites your program into continuation-passing style: every call becomes a step in a state machine whose entire state is serialised to disk after each one. The consequences you will actually meet:

Every local variable that is live across a step must be Serializable. A Matcher from =~, a File, an InputStream, a SimpleDateFormat — hold one across a sh or a powershell and you get a NotSerializableException naming a class you did not think you were storing.

// Fails on the powershell step: the Matcher is a live local.
def m = readFile('version.txt') =~ /(\d+)\.(\d+)/
powershell 'ci\\build.ps1'
echo m[0][1]

// Fine. The Matcher never crosses a step boundary.
@NonCPS
String major(String text) {
  def m = text =~ /(\d+)\.(\d+)/
  return m ? m[0][1] : null
}

Groovy's own iteration methods are not transformed, so a closure body inside .each { } does not behave quite like the body of a for loop when it calls steps. java.util.stream does not work at all, because the JDK is not transformed and your closure is. And the escape hatch, @NonCPS, runs a method as plain Groovy — which means no pipeline steps inside it, and whatever it returns had better be serialisable.

It is sandboxed. Groovy script security intercepts every method call and checks it against a whitelist. new File(...), Jenkins.instance, System.getenv() — blocked, pending an administrator approving it in In-process Script Approval.

That is correct behaviour, and it is worth saying so plainly: a Jenkinsfile is code from a repository, executed on the controller, and an unsandboxed Jenkinsfile is remote code execution against your CI. It also means that a large share of the Groovy you find online is script-console Groovy, written by an administrator with no sandbox, and it will never run in a pipeline.

Put the three together and the practical rule falls out on its own. A Jenkinsfile is a declarative document with a small amount of orchestration in it. When I want to write a program, I write it in a language the machine is going to run normally — which by this month means Python, for the reasons I went through a fortnight ago.

Where else "everywhere" runs out

Native images. GraalVM's native-image needs to know at build time what can be called. A runtime metaobject protocol is close to the opposite of that. @CompileStatic on everything helps and it is not a solved problem.

Android. There was a serious effort to make Groovy work there. The dynamic runtime and the DEX toolchain never came to terms.

Startup. JVM start plus Groovy's runtime initialisation is a good fraction of a second before your first line executes. Invisible inside the Gradle daemon. Very visible in a CLI you call in a loop, which is precisely the shape of most automation.

Tooling. Gradle's Kotlin DSL exists because an IDE can complete Kotlin and cannot reliably complete a dynamic Groovy DSL. That is a real structural disadvantage, not a fashion, and it is the same trade in a different suit: everything the metaclass gives you at runtime, it takes away from anything trying to reason about your code beforehand.

So: everywhere?

Groovy runs anywhere a JVM runs, and it is present in far more places than its reputation suggests. But "Groovy everywhere" is true the way "bash everywhere" is true. It is the language you find at the seams of systems built in something else, doing configuration, glue and orchestration, and it is genuinely excellent at that.

Jenkins is where somebody asked it to do more than that, and the result is a dialect frozen at 2.4, compiled through a rewriter, executed behind a whitelist, in which a regex match is a bug waiting for the next step.

Learn enough to read a build.gradle and write a clean Jenkinsfile. It is about a weekend, and it will pay for itself the first time an error message mentions CpsCallableInvocation. Then keep your logic somewhere you can test it.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev