Run a bare println from a .groovy file and it just works, while the same line in Java demands a class and a main method. That magic has rules, and they bite once scripts grow. This guide walks the Groovy script vs class divide with 12 tested examples, from the wrapper class the compiler generates to the binding rules that decide which variables your methods can see.
“A script is just a class that forgot to introduce itself – the compiler fills in the formalities so you can get straight to the point.”
Last Updated: July 2026 | Tested on: Groovy 5.0.6, Java 17+ | Difficulty: Intermediate | Reading Time: 16 minutes
Understanding the groovy script vs class distinction – explained in the official Groovy documentation on scripts versus classes – starts with a simple observation: every Groovy developer starts with a script. You write println 'Hello' in a .groovy file, run it, and it works. No public static void main, no class declaration, no ceremony. But that simplicity hides a real compilation step – the Groovy compiler wraps your script in a class that extends groovy.lang.Script, generates a run() method containing your top-level code, and compiles it to JVM bytecode just like any other class.
Understanding how scripts differ from class-based Groovy files matters when your projects grow. Scripts have different scoping rules, a special Binding object for untyped variables, and quirks around def that trip up intermediate developers. Class-based files give you explicit structure, proper encapsulation, and predictable classpath behavior. Most real-world Groovy projects mix both – scripts for automation and glue, classes for domain logic and libraries.
This post covers the full picture: how scripts compile to Script subclasses, how binding and declared variables work, the @Field annotation, running scripts from the CLI, embedding scripts with GroovyShell and GroovyScriptEngine, and writing proper class-based Groovy files. If you want to understand the compilation pipeline in more depth, check our Groovy Compilation Lifecycle guide first.
What you’ll learn:
- How Groovy scripts compile to a
Scriptsubclass with arun()method - The difference between binding variables and locally declared variables
- How
defchanges variable scope in scripts - How
@Fieldpromotes a variable to a class-level field - Running scripts from the command line with
groovyandgroovyc - Embedding and evaluating scripts with
GroovyShellandGroovyScriptEngine - Writing class-based Groovy files and understanding the differences
Table of Contents
Quick Reference: Script vs Class
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
How Groovy Scripts Compile
The diagram above shows the fork in the road. A file with a class declaration compiles as-is, exactly like Java. A file with loose top-level code gets wrapped: the compiler generates a class named after the file, extends groovy.lang.Script, moves your code into a run() method, and adds a main() entry point. The examples below poke at every consequence of that wrapping.
12 Groovy Script vs Class Examples
Example 1: Basic Script – What the Compiler Generates
What we’re doing: Writing a minimal script and examining the generated class structure to prove that scripts are compiled classes.
Example 1: Script Compilation
// File: HelloScript.groovy
// This is a script - no class declaration
def message = 'Hello from a script!'
println message
// Let's prove this is a real class
println "Script class: ${this.getClass().name}"
println "Superclass: ${this.getClass().superclass.name}"
println "run() method: ${this.getClass().declaredMethods*.name.contains('run')}"
// The script itself is 'this'
println "this is Script? ${this instanceof groovy.lang.Script}"
Output
Hello from a script! Script class: HelloScript Superclass: groovy.lang.Script run() method: true this is Script? true
What happened here: The Groovy compiler took our script file HelloScript.groovy and generated a class called HelloScript that extends groovy.lang.Script. All our top-level code was placed inside a run() method. The this reference inside a script points to the script instance itself, which is why this instanceof groovy.lang.Script returns true. This is not interpretation – it is genuine compilation to JVM bytecode.
Example 2: Binding vs Declared Variables
What we’re doing: Demonstrating the critical difference between untyped variables (which go into the Binding) and def-declared variables (which are local to run()).
Example 2: Binding vs def
// Untyped variable - goes into Binding
city = 'London'
// def variable - local to run()
def country = 'UK'
// Method defined in script
void showLocation() {
// 'city' is accessible - it's in the Binding
println "City: ${city}"
// 'country' would fail here - it's local to run()
try {
println "Country: ${country}"
} catch (MissingPropertyException e) {
println "Country not accessible: ${e.message}"
}
}
showLocation()
// Prove binding contains 'city' but not 'country'
println "\nBinding variables: ${binding.variables.keySet()}"
println "city in binding? ${binding.hasVariable('city')}"
Output
City: London Country not accessible: No such property: country for class: ScriptExample2 Binding variables: [args, city] city in binding? true
What happened here: When you write city = 'London' without def or a type, Groovy stores it in the script’s Binding object – a shared map accessible to all methods in the script. But when you write def country = 'UK', that becomes a local variable inside the run() method and is invisible to other methods. This is the single most important scoping rule in Groovy scripts: untyped = binding, typed/def = local.
Example 3: The @Field Annotation
What we’re doing: Using @Field to promote a def variable from a local variable to a proper instance field on the generated Script class.
Example 3: @Field Annotation
import groovy.transform.Field
// Without @Field - local to run()
def localCounter = 0
// With @Field - becomes an instance field on the Script class
@Field int fieldCounter = 0
@Field List<String> log = []
void increment() {
fieldCounter++
log << "Incremented to ${fieldCounter}"
// localCounter is NOT accessible here
try {
localCounter++
} catch (MissingPropertyException e) {
log << "localCounter not accessible in method"
}
}
increment()
increment()
increment()
println "fieldCounter: ${fieldCounter}"
println "Log entries:"
log.each { println " - ${it}" }
// Verify @Field created a real class field
def fields = this.getClass().declaredFields*.name
println "\nDeclared fields: ${fields.findAll { !it.startsWith('$') && !it.startsWith('__') }}"
Output
fieldCounter: 3 Log entries: - Incremented to 1 - localCounter not accessible in method - Incremented to 2 - localCounter not accessible in method - Incremented to 3 - localCounter not accessible in method Declared fields: [fieldCounter, log]
What happened here: The @Field annotation (from groovy.transform) tells the compiler to make the variable an instance field on the generated Script class instead of a local variable in run(). This means fieldCounter and log are accessible from any method defined in the script. Without @Field, localCounter stays trapped inside run(). Use @Field when you need script-level state that multiple methods can share.
Example 4: Script Methods and Scope
What we’re doing: Defining multiple methods at the top level of a script and showing how they become methods on the compiled Script class.
Example 4: Script Methods
import groovy.transform.Field
@Field String appName = 'DataProcessor'
// Methods defined at top level become methods on the Script class
String greet(String name) {
return "${appName} welcomes ${name}"
}
int add(int a, int b) {
return a + b
}
List<String> filterLong(List<String> words, int minLength) {
return words.findAll { it.length() >= minLength }
}
// Top-level code calls these methods
println greet('Nirranjan')
println "2 + 3 = ${add(2, 3)}"
def words = ['Groovy', 'is', 'a', 'powerful', 'language']
println "Long words: ${filterLong(words, 4)}"
// Prove these are real methods on the Script class
def methodNames = this.getClass().declaredMethods*.name
.findAll { !it.startsWith('$') && !it.startsWith('super$') }
.sort()
println "\nScript methods: ${methodNames}"
Output
DataProcessor welcomes Nirranjan 2 + 3 = 5 Long words: [Groovy, powerful, language] Script methods: [add, filterLong, greet, main, run, setAppName]
What happened here: Every method you define at the top level of a script becomes a method on the generated Script class. The compiler also adds run() (containing your top-level statements) and main() (the JVM entry point that creates the script instance and calls run()). Notice the extra setAppName method in the list: because @Field String appName becomes a real instance field, Groovy generates the matching setter for it, so property-style writes still work. These methods can access @Field variables and binding variables, but not local def variables from run().
Example 5: Class-Based Groovy File
What we’re doing: Writing a proper class-based Groovy file with static main and comparing it to the script approach.
Example 5: Class-Based Execution
// File: Calculator.groovy
// This is a class-based file - NOT a script
class Calculator {
String name
List<String> history = []
Calculator(String name) {
this.name = name
}
double add(double a, double b) {
def result = a + b
history << "${a} + ${b} = ${result}"
return result
}
double multiply(double a, double b) {
def result = a * b
history << "${a} * ${b} = ${result}"
return result
}
void printHistory() {
println "=== ${name} History ==="
history.eachWithIndex { entry, i ->
println " ${i + 1}. ${entry}"
}
}
// Explicit entry point
static void main(String[] args) {
def calc = new Calculator('MyCalc')
println "Result: ${calc.add(10, 20)}"
println "Result: ${calc.multiply(5, 8)}"
println "Result: ${calc.add(100, 0.5)}"
calc.printHistory()
// This is a regular class, not a Script
println "\nIs Script? ${calc instanceof groovy.lang.Script}"
println "Class: ${calc.getClass().name}"
println "Superclass: ${calc.getClass().superclass.name}"
}
}
Output
Result: 30.0 Result: 40.0 Result: 100.5 === MyCalc History === 1. 10.0 + 20.0 = 30.0 2. 5.0 * 8.0 = 40.0 3. 100.0 + 0.5 = 100.5 Is Script? false Class: Calculator Superclass: java.lang.Object
What happened here: A class-based Groovy file compiles to a standard JVM class that extends java.lang.Object – not groovy.lang.Script. There is no Binding, no implicit run() method, and no special scope rules. All variables are either fields or local variables with clear, predictable scoping. You need an explicit static main(String[] args) to run it from the command line. This is the style to use for anything beyond quick automation scripts.
Example 6: Mixed File – Script with Embedded Classes
What we’re doing: Defining classes inside a script file and showing how both coexist in the same compilation unit.
Example 6: Classes Inside Scripts
// File: MixedFile.groovy
// This is still a SCRIPT because it has top-level statements
// Class defined inside the script
class Person {
String name
int age
String toString() { "${name} (age ${age})" }
}
class Team {
String teamName
List<Person> members = []
void addMember(Person p) { members << p }
String toString() {
def memberList = members.collect { " - ${it}" }.join('\n')
"${teamName}:\n${memberList}"
}
}
// Top-level script code uses the classes
def team = new Team(teamName: 'Engineering')
team.addMember(new Person(name: 'Nirranjan', age: 30))
team.addMember(new Person(name: 'Viraj', age: 25))
team.addMember(new Person(name: 'Prathamesh', age: 35))
println team
// The script class and the inner classes are separate
println "\nScript class: ${this.getClass().name}"
println "Person class: ${Person.name}"
println "Team class: ${Team.name}"
println "Script extends Script? ${this instanceof groovy.lang.Script}"
println "Person extends Script? ${new Person() instanceof groovy.lang.Script}"
Output
Engineering: - Nirranjan (age 30) - Viraj (age 25) - Prathamesh (age 35) Script class: MixedFile Person class: Person Team class: Team Script extends Script? true Person extends Script? false
What happened here: A .groovy file can contain both class definitions and top-level statements. When top-level statements are present, the file is treated as a script. The compiler generates a Script subclass (named after the file) for the top-level code, and compiles the embedded classes as separate, normal JVM classes. The embedded Person and Team classes do not extend Script – they are regular classes that happen to be defined in the same file.
Example 7: Running Scripts from the Command Line
What we’re doing: Showing the different ways to run Groovy scripts and classes from the CLI, including passing arguments.
Example 7: CLI Execution
// File: CliDemo.groovy
// Script that processes command-line arguments
println "Script name: ${this.getClass().name}"
println "Number of arguments: ${args.length}"
if (args.length == 0) {
println "Usage: groovy CliDemo.groovy <name> [options...]"
println "(Running with default values for demo)"
// Simulate args for demonstration
def demoArgs = ['Nirranjan', '--verbose', '--output=report.txt']
processArgs(demoArgs as String[])
} else {
processArgs(args)
}
void processArgs(String[] arguments) {
println "\n--- Processing Arguments ---"
def name = arguments[0]
def flags = arguments.findAll { it.startsWith('--') }
def kvPairs = flags.findAll { it.contains('=') }
.collectEntries { it.split('=', 2).with { [(it[0]): it[1]] } }
def boolFlags = flags.findAll { !it.contains('=') }
println "Name: ${name}"
println "Bool flags: ${boolFlags}"
println "KV pairs: ${kvPairs}"
}
Output
Script name: CliDemo Number of arguments: 0 Usage: groovy CliDemo.groovy <name> [options...] (Running with default values for demo) --- Processing Arguments --- Name: Nirranjan Bool flags: [--verbose] KV pairs: [--output:report.txt]
What happened here: The args variable is automatically available in every Groovy script – it is a String[] containing the command-line arguments. Run the script with groovy CliDemo.groovy Nirranjan --verbose and those values populate args. This works because the generated main(String[] args) passes the arguments to the script’s Binding. In class-based files, you handle args yourself in static main.
Example 8: GroovyShell – Evaluating Scripts Programmatically
What we’re doing: Using GroovyShell to evaluate script strings at runtime, passing variables through a Binding.
Example 8: GroovyShell
// Evaluate a simple expression
def shell = new GroovyShell()
def result = shell.evaluate('2 + 3 * 4')
println "Expression result: ${result}"
// Pass variables via Binding
def binding = new Binding()
binding.setVariable('name', 'Groovy Developer')
binding.setVariable('items', [10, 20, 30, 40, 50])
def shell2 = new GroovyShell(binding)
shell2.evaluate('''
println "Hello, ${name}!"
println "Sum of items: ${items.sum()}"
println "Average: ${items.sum() / items.size()}"
// Script can set binding variables too
result = items.collect { it * 2 }
''')
// Read back variables set by the script
println "Doubled items: ${binding.getVariable('result')}"
// Parse without executing - returns a Script object
def script = shell.parse('''
return "Parsed at ${new Date().format('HH:mm:ss')}"
''')
println "\nScript class: ${script.getClass().superclass.name}"
println "Run result: ${script.run()}"
// Rerun the same parsed script
Thread.sleep(1000)
println "Rerun result: ${script.run()}"
Output
Expression result: 14 Hello, Groovy Developer! Sum of items: 150 Average: 30 Doubled items: [20, 40, 60, 80, 100] Script class: groovy.lang.Script Run result: Parsed at 14:23:07 Rerun result: Parsed at 14:23:08
What happened here: GroovyShell is the simplest way to evaluate Groovy code at runtime. Call evaluate() to compile and run a script string in one step, or call parse() to compile it into a Script object you can run later (or multiple times). The two Parsed at timestamps come from new Date() and reflect the wall-clock time on each run, so the exact values change every time and the second is one second later because of the Thread.sleep(1000).
The Binding object acts as a shared variable map between the host code and the evaluated script – the script reads variables from the binding and can write new ones back. This is the foundation for plugin systems, rule engines, and configuration DSLs. For more on embedding Groovy, see our Groovy Shell guide.
Example 9: GroovyScriptEngine – File-Based Script Loading
What we’re doing: Using GroovyScriptEngine to load and run scripts from file paths, with automatic recompilation on change.
Example 9: GroovyScriptEngine
import groovy.util.GroovyScriptEngine
// Create a temp directory with script files
def scriptDir = File.createTempDir('groovy-scripts-', '')
// Write a utility script
new File(scriptDir, 'utils.groovy').text = '''
def formatCurrency(double amount) {
return String.format('$%.2f', amount)
}
return this
'''
// Write a main script that uses the utility
new File(scriptDir, 'report.groovy').text = '''
println "Generating report for: ${name}"
println "Items:"
items.each { item ->
println " - ${item.name}: ${item.price}"
}
total = items.collect { it.price }.sum()
println "Total: " + String.format('$%.2f', total)
'''
// Create engine rooted at script directory
def engine = new GroovyScriptEngine(scriptDir.absolutePath)
// Run script with binding
def binding = new Binding()
binding.setVariable('name', 'Q1 Sales')
binding.setVariable('items', [
[name: 'Widget A', price: 29.99],
[name: 'Widget B', price: 49.99],
[name: 'Service C', price: 149.00]
])
engine.run('report.groovy', binding)
println "\nTotal from binding: ${binding.getVariable('total')}"
// GroovyScriptEngine advantages over GroovyShell:
println "\n--- GroovyScriptEngine Features ---"
println "1. Loads from file paths (not strings)"
println "2. Caches compiled scripts"
println "3. Auto-recompiles when source changes"
println "4. Resolves script dependencies"
// Cleanup
scriptDir.deleteDir()
Output
Generating report for: Q1 Sales Items: - Widget A: 29.99 - Widget B: 49.99 - Service C: 149.00 Total: $228.98 Total from binding: 228.98 --- GroovyScriptEngine Features --- 1. Loads from file paths (not strings) 2. Caches compiled scripts 3. Auto-recompiles when source changes 4. Resolves script dependencies
What happened here: GroovyScriptEngine is designed for running scripts from the filesystem. You point it at one or more root directories, and it loads scripts by filename. Unlike GroovyShell, it caches compiled scripts and automatically recompiles them when the source file changes – ideal for plugin directories or hot-reloading scenarios. The engine also resolves inter-script dependencies, so one script can reference classes defined in another script in the same root directory.
Example 10: Compiling Scripts with groovyc
What we’re doing: Demonstrating how groovyc compiles a script to .class files and what those class files contain.
Example 10: groovyc Compilation
// Simulate what groovyc does by using GroovyClassLoader
def loader = new GroovyClassLoader()
// A script
def scriptClass = loader.parseClass('''
// ScriptDemo.groovy
x = 42
def y = 100
println "x = ${x}, y = ${y}"
''', 'ScriptDemo.groovy')
// A class-based file
def classFileClass = loader.parseClass('''
// AppService.groovy
class AppService {
String process(String input) {
return input.toUpperCase()
}
}
''', 'AppService.groovy')
println "=== Script Compilation ==="
println "Class name: ${scriptClass.name}"
println "Superclass: ${scriptClass.superclass.name}"
println "Methods: ${scriptClass.declaredMethods*.name.sort().unique()}"
println "\n=== Class-Based Compilation ==="
println "Class name: ${classFileClass.name}"
println "Superclass: ${classFileClass.superclass.name}"
println "Methods: ${classFileClass.declaredMethods*.name.sort().unique()}"
println "\n=== Key Difference ==="
println "Script has run(): ${scriptClass.declaredMethods*.name.contains('run')}"
println "Class has run(): ${classFileClass.declaredMethods*.name.contains('run')}"
println "Script has main(): ${scriptClass.declaredMethods*.name.contains('main')}"
println "Class has main(): ${classFileClass.declaredMethods*.name.contains('main')}"
Output
=== Script Compilation === Class name: ScriptDemo Superclass: groovy.lang.Script Methods: [$getStaticMetaClass, main, run] === Class-Based Compilation === Class name: AppService Superclass: java.lang.Object Methods: [$getStaticMetaClass, getMetaClass, process, setMetaClass] === Key Difference === Script has run(): true Class has run(): false Script has main(): true Class has main(): false
What happened here: We used GroovyClassLoader.parseClass() to simulate what groovyc does. The script file produces a class extending groovy.lang.Script with auto-generated run() and main() methods. The class-based file produces a plain class extending Object that carries your process method plus a few Groovy-injected helpers: $getStaticMetaClass, getMetaClass, and setMetaClass are added to every compiled Groovy class so the runtime metaobject protocol works, and they are not methods you wrote. When you run groovyc MyScript.groovy from the command line, it generates MyScript.class – and you can run that with java -cp groovy-all.jar:. MyScript just like any Java class.
Example 11: Script Base Class – Custom Script Superclass
What we’re doing: Configuring GroovyShell to use a custom base class for scripts, adding utility methods available to all evaluated scripts.
Example 11: Custom Script Base Class
import org.codehaus.groovy.control.CompilerConfiguration
// Define a custom base class for scripts
abstract class EnhancedScript extends Script {
// Helper methods available to all scripts
void info(String msg) {
println "[INFO] ${new Date().format('HH:mm:ss')} - ${msg}"
}
void warn(String msg) {
println "[WARN] ${new Date().format('HH:mm:ss')} - ${msg}"
}
void error(String msg) {
println "[ERROR] ${new Date().format('HH:mm:ss')} - ${msg}"
}
Map<String, Object> timer(String label, Closure action) {
def start = System.nanoTime()
def result = action()
def elapsed = (System.nanoTime() - start) / 1_000_000.0
info "${label} completed in ${elapsed.round(2)}ms"
return [result: result, elapsed: elapsed]
}
}
// Configure the shell to use our custom base class
def config = new CompilerConfiguration()
config.scriptBaseClass = EnhancedScript.name
def shell = new GroovyShell(this.class.classLoader, new Binding(), config)
// Now every script gets our helper methods for free
shell.evaluate('''
info 'Starting data processing'
timer('Sort operation') {
(1..10000).toList().shuffled().sort()
}
warn 'Large dataset detected'
timer('Filter operation') {
(1..10000).findAll { it % 7 == 0 }
}
info 'Processing complete'
''')
Output
[INFO] 14:23:10 - Starting data processing [INFO] 14:23:10 - Sort operation completed in 45.32ms [WARN] 14:23:10 - Large dataset detected [INFO] 14:23:10 - Filter operation completed in 12.18ms [INFO] 14:23:10 - Processing complete
What happened here: By setting CompilerConfiguration.scriptBaseClass, we told GroovyShell to use EnhancedScript instead of the default groovy.lang.Script as the superclass for all parsed scripts. This means every script automatically inherits info(), warn(), error(), and timer(). The timestamps and the millisecond timings in the output come from the live clock and from System.nanoTime(), so the exact numbers vary with your machine and each run. This pattern is how frameworks like Gradle and Jenkins build their DSLs – they provide a rich base class, and user scripts just call the inherited methods as if they were built-in keywords.
Example 12: Choosing Between Script and Class in Practice
What we’re doing: A side-by-side comparison implementing the same task as both a script and a class, highlighting when each approach is better.
Example 12: Script vs Class Side-by-Side
// === APPROACH 1: Script Style ===
// Quick, concise, ideal for one-off tasks
import groovy.transform.Field
@Field Map<String, List<Integer>> scores = [:]
void addScore(String student, int score) {
if (!scores[student]) scores[student] = []
scores[student] << score
}
double average(String student) {
def s = scores[student]
return s ? s.sum() / s.size() : 0.0
}
addScore('Nirranjan', 85)
addScore('Nirranjan', 92)
addScore('Viraj', 78)
addScore('Viraj', 88)
addScore('Viraj', 95)
println "=== Script Approach ==="
scores.each { name, s ->
println "${name}: avg = ${String.format('%.1f', average(name))} from ${s.size()} scores"
}
// === APPROACH 2: Class Style ===
// Structured, testable, reusable
class GradeBook {
private Map<String, List<Integer>> records = [:]
void addScore(String student, int score) {
records.computeIfAbsent(student) { [] } << score
}
double average(String student) {
def s = records[student]
return s ? s.sum() / s.size() : 0.0
}
Map<String, Double> allAverages() {
return records.collectEntries { name, s ->
[(name): s.sum() / s.size()]
}
}
String report() {
def sb = new StringBuilder()
allAverages().each { name, avg ->
sb.append("${name}: avg = ${String.format('%.1f', avg)} ")
sb.append("from ${records[name].size()} scores\n")
}
return sb.toString().trim()
}
}
def book = new GradeBook()
book.addScore('Nirranjan', 85)
book.addScore('Nirranjan', 92)
book.addScore('Viraj', 78)
book.addScore('Viraj', 88)
book.addScore('Viraj', 95)
println "\n=== Class Approach ==="
println book.report()
// The class approach is testable
assert book.average('Nirranjan') == 88.5
assert book.average('Viraj') == 87.0
assert book.allAverages().size() == 2
println "\nAll assertions passed!"
Output
=== Script Approach === Nirranjan: avg = 88.5 from 2 scores Viraj: avg = 87.0 from 3 scores === Class Approach === Nirranjan: avg = 88.5 from 2 scores Viraj: avg = 87.0 from 3 scores All assertions passed!
What happened here: Both approaches produce the same output, but they differ in structure and intent. The script version is shorter and faster to write – great for a one-off report or build task. The class version is encapsulated, testable, and reusable – you can import GradeBook in other files, write unit tests against it, and extend it. The rule of thumb: if you’ll use the code once, script it. If you’ll use it twice or more, class it.
Common Pitfalls
Pitfall 1: def Variables Invisible to Script Methods
The most common mistake in Groovy scripts is declaring a variable with def and expecting it to be available in methods.
Common Mistake: def Scope in Scripts
// BAD - def makes it local to run()
def config = [timeout: 30, retries: 3]
void connect() {
// This FAILS: config is not in scope
// println "Timeout: ${config.timeout}"
println "ERROR: config is not accessible here"
}
// GOOD - use @Field or untyped
import groovy.transform.Field
@Field Map goodConfig = [timeout: 30, retries: 3]
void connectProperly() {
println "Timeout: ${goodConfig.timeout}" // Works!
}
// ALSO GOOD - untyped goes to Binding
altConfig = [timeout: 60, retries: 5]
void connectAlt() {
println "Retries: ${altConfig.retries}" // Works!
}
connect()
connectProperly()
connectAlt()
Pitfall 2: Script Name Conflicts with Class Names
If your script file is named String.groovy or List.groovy, the generated Script class will shadow the JDK class of the same name – causing baffling errors.
Common Mistake: Name Conflicts
// BAD - Don't name your script file "String.groovy" or "Map.groovy" // The generated class "String" or "Map" shadows java.lang.String / java.util.Map // BAD script filenames: // String.groovy -> class String extends Script (shadows java.lang.String!) // List.groovy -> class List extends Script (shadows java.util.List!) // Main.groovy -> fine, but confusing if you also have a Main class // GOOD naming conventions for scripts: // run-report.groovy (hyphens get converted to valid class names) // deploy_app.groovy (underscores are fine) // DataImport.groovy (descriptive, no conflict) println "Always use descriptive, unique names for script files" println "Avoid names that match JDK classes or your own domain classes"
Pitfall 3: GroovyShell Memory Leaks
Every call to GroovyShell.evaluate() or parse() compiles a new class. In a loop, this creates hundreds of classes that accumulate in the classloader, eventually causing OutOfMemoryError: Metaspace.
Common Mistake: GroovyShell in Loops
// BAD - creates a new class on every iteration
def shell = new GroovyShell()
// for (i in 1..10000) {
// shell.evaluate("println ${i}") // 10,000 compiled classes!
// }
// GOOD - parse once, run many times with different bindings
def script = shell.parse('println "Processing item: ${item}"')
for (i in 1..5) {
script.binding = new Binding([item: i])
script.run()
}
// ALSO GOOD - use GroovyScriptEngine for file-based scripts
// It caches compiled scripts automatically
println "\nParse once, run many = no memory leak"
Best Practices
After working through all 12 examples, here are the guidelines for choosing between scripts and classes in Groovy.
- DO use scripts for build tasks, automation, one-off data processing, and CLI tools.
- DO use class-based files for domain models, libraries, APIs, and anything you’ll unit test.
- DO use
@Fieldwhen you need script-level state shared across methods – it makes the intent clear. - DO prefer untyped binding variables sparingly – they are convenient but make code harder to reason about.
- DO use
GroovyShell.parse()when you need to run the same script multiple times – parse once, run many. - DO use
GroovyScriptEnginefor file-based plugin systems – it handles caching and recompilation. - DON’T use
defin scripts when you expect the variable to be accessible from methods – use@Fieldor untyped instead. - DON’T name script files after JDK classes (
String.groovy,List.groovy) – the generated class will shadow them. - DON’T call
GroovyShell.evaluate()in a tight loop – each call compiles a new class, leading to metaspace exhaustion. - DON’T mix scripts and classes in the same source tree without clear naming conventions – it confuses the build tool and your teammates.
Conclusion
Groovy scripts and class-based files are two sides of the same coin. Scripts give you speed and brevity – write code, run it, done. The compiler handles the class generation, the run() method, and the entry point. Class-based files give you structure, encapsulation, and testability – the structure you need for production code. Understanding how scripts compile to Script subclasses, how binding and declared variables differ, and how @Field bridges the gap makes the Groovy script vs class decision easy to call on any given file.
For embedding Groovy in applications, GroovyShell handles simple evaluation, GroovyScriptEngine manages file-based scripts with caching, and custom base classes let you build rich DSLs. Most real-world projects use a mix: scripts for build automation and operational tasks (think Gradle, Jenkins), classes for the core domain logic. The trick is knowing when to reach for which.
To understand the compilation pipeline in more depth, read our Groovy Compilation Lifecycle guide. For the runtime metaprogramming techniques that scripts make easy, see Groovy Metaprogramming. And to explore the toolkit that makes Groovy scripts so powerful, head to the next post: GDK (Groovy Development Kit) Overview.
Up next: GDK (Groovy Development Kit) Overview
Frequently Asked Questions
What is the difference between a Groovy script and a Groovy class file?
A Groovy script is a .groovy file without a class declaration – the compiler wraps it in a class extending groovy.lang.Script with an auto-generated run() method. A class-based file contains an explicit class declaration and compiles to a standard JVM class extending java.lang.Object. Scripts have special scoping rules (binding vs local variables) and can be run directly. Class files need an explicit static main() method or must be instantiated by other code. That wrapping is the heart of every Groovy script vs class difference in this post.
Why can’t my script method access a variable declared with def?
In a Groovy script, def makes a variable local to the generated run() method. Methods defined in the script are separate methods on the Script class – they cannot see run()‘s local variables. To share state, either use @Field to promote the variable to an instance field, or omit the type declaration so the variable goes into the Binding (accessible to all methods).
What does @Field do in a Groovy script?
The @Field annotation (from groovy.transform) tells the Groovy compiler to make a script variable an instance field on the generated Script class instead of a local variable in the run() method. This makes the variable accessible from all methods defined in the script. Without @Field, variables declared with def or a type are only visible within the top-level code block.
What is the difference between GroovyShell and GroovyScriptEngine?
GroovyShell evaluates Groovy code from strings or files one at a time. It’s simple but creates a new class for each evaluate() call. GroovyScriptEngine is designed for managing multiple script files – it caches compiled scripts, auto-recompiles when source files change, and resolves inter-script dependencies. Use GroovyShell for simple one-off evaluation and GroovyScriptEngine for file-based plugin systems or hot-reload scenarios.
Can I define classes inside a Groovy script?
Yes. A Groovy script file can contain both class definitions and top-level statements. The compiler generates a Script subclass for the top-level code and compiles embedded classes as separate, normal JVM classes. The embedded classes do not extend Script – they are regular classes. This is useful for defining helper types used only by the script, though for larger projects it’s better to put classes in their own files.
How do I pass variables to a Groovy script from Java?
Create a Binding object, set variables on it with binding.setVariable('name', value), and pass the binding to GroovyShell or GroovyScriptEngine. The script accesses these variables as if they were declared at the top level. After execution, you can read back any variables the script set by calling binding.getVariable('result'). This is the standard way to communicate between host Java/Groovy code and evaluated scripts.
Interview Questions on Groovy Scripts and Classes
Scenario questions, not trivia: this is the form this topic takes in a real interview.
Q: In a Jenkins pipeline, a value assigned without def in one parallel branch mysteriously shows up, half-updated, in another branch. A colleague blames Jenkins. What’s the Groovy explanation?
An undeclared assignment in a script doesn’t create a local variable; it writes into the script’s Binding, which is one shared object for the whole pipeline run. Parallel branches are threads reading and writing that same binding, so you get a textbook data race. Declaring the variable with def makes it local to the enclosing scope, which is both thread-safe and what the author meant. This one line of scoping theory explains a whole family of “flaky pipeline” bugs, which is exactly why interviewers in Jenkins shops love it.
Q: You inherit a 400-line operational script with zero tests. How do you get it under Spock without rewriting it as classes first?
A script is just a class, so treat it like one. From a Spock spec you can run it with a controlled Binding through GroovyShell, injecting fake inputs and asserting on variables the script sets. If it’s compiled on the classpath, you can even instantiate the generated Script subclass and call run() directly. That gets a safety net in place. Then, incrementally, I’d extract the logic into real classes with the script becoming a thin wiring layer, because binding-based tests are coarse and can’t mock collaborators cleanly.
Q: A service evaluates the same rule script on every request with shell.evaluate(ruleText), and Metaspace usage climbs steadily. What’s wrong, and what’s the right pattern?
Every evaluate() compiles the source into a brand new class, so a hot endpoint mints thousands of classes that never unload while their loader is referenced. The fix is compile once, instantiate many: parse the script to a Class up front, then per request create a fresh instance with its own Binding, via InvokerHelper.createScript(), and call run(). One caution worth saying aloud: reuse the compiled Class, never a Script instance across threads, because the instance carries its binding as mutable state.
Q: You compile a working script with groovyc, put the .class in a jar, and run it with plain java -cp app.jar. It dies instantly with NoClassDefFoundError: groovy/lang/Script. Why did the groovy command never have this problem?
Because the groovy launcher quietly puts the whole Groovy runtime on the classpath, and the generated class literally extends groovy.lang.Script, so the JVM must load that superclass before anything runs. Under plain java, that jar isn’t there. The fixes are standard packaging choices: add the Groovy jars to the classpath, build a fat jar that bundles them, or in Gradle treat the script like an application with the groovy dependency declared. The underlying lesson: compiled Groovy is normal bytecode with a library dependency, not a self-contained artifact.
Q: Inside one script file, top-level code uses an inputDir binding variable happily, but a helper class declared in the same file throws MissingPropertyException when it tries the same. Why the asymmetry?
Only the generated Script subclass is wired to the Binding; its property access falls back to binding lookup by design. A class declared inside the script file compiles into a completely ordinary class with no relationship to the script or its binding, so inputDir is just a missing property there. The clean fix is passing the value in explicitly, through a constructor or method parameter, which is also better design. Interviewers use this to check you know the binding is a Script feature, not a file-level scope.
Related Posts
Previous in Series: Groovy Compilation Lifecycle
Next in Series: GDK (Groovy Development Kit) Overview
Related Topics You Might Like:
- Groovy Shell – Interactive Groovy Programming
- Groovy Metaprogramming – Runtime and Compile-Time
- Groovy AST Transformations – Compile-Time Metaprogramming
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment