Learn the Groovy script vs class differences in execution with 12 tested examples. Covers Script subclass compilation, binding vs declared variables, @Field, GroovyShell, and GroovyScriptEngine.
“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.”
Venkat Subramaniam, Programming Groovy 2
Last Updated: March 2026 | Tested on: Groovy 5.x, 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
| Feature | Script (.groovy without class declaration) | Class-Based (.groovy with class declaration) |
|---|---|---|
| Compiled to | Subclass of groovy.lang.Script | Standard JVM class |
| Entry point | Auto-generated run() method | Explicit static main() or none |
| Untyped variables | Stored in Binding | Not allowed without def |
def variables | Local to run() | Class fields or local variables |
@Field | Promotes to instance field | Not needed (already fields) |
| Methods defined at top level | Become methods on the Script class | Not applicable |
| CLI execution | groovy MyScript.groovy | groovy MyClass.groovy (needs main) |
| Embedding | GroovyShell, GroovyScriptEngine | GroovyClassLoader |
| Best for | Automation, glue code, quick tasks | Libraries, domain models, large projects |
How Groovy Scripts Compile
When you write a Groovy script – a .groovy file without any class declaration – the Groovy compiler does the following:
- Creates a class with the same name as the file (e.g.,
MyScript.groovybecomes classMyScript) - Makes that class extend
groovy.lang.Script - Wraps all top-level statements into a
run()method - Top-level method definitions become methods on the generated class
- Generates a
static main(String[] args)that creates a new instance and callsrun()
This means every Groovy script is ultimately a class – there is no interpretation step. The difference between a “script” and a “class file” is purely about how the compiler wraps your code.
12 Practical 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: [city, args] 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]
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()). 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 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.0 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, process] === 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 with only the methods you declared. 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(). 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 you a more effective Groovy developer.
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.
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.
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