Groovy Script vs Class – 10+ Tested Examples

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 Script subclass with a run() method
  • The difference between binding variables and locally declared variables
  • How def changes variable scope in scripts
  • How @Field promotes a variable to a class-level field
  • Running scripts from the command line with groovy and groovyc
  • Embedding and evaluating scripts with GroovyShell and GroovyScriptEngine
  • Writing class-based Groovy files and understanding the differences

Quick Reference: Script vs Class

FeatureScript (.groovy without class declaration)Class-Based (.groovy with class declaration)
Compiled toSubclass of groovy.lang.ScriptStandard JVM class
Entry pointAuto-generated run() methodExplicit static main() or none
Untyped variablesStored in BindingNot allowed without def
def variablesLocal to run()Class fields or local variables
@FieldPromotes to instance fieldNot needed (already fields)
Methods defined at top levelBecome methods on the Script classNot applicable
CLI executiongroovy MyScript.groovygroovy MyClass.groovy (needs main)
EmbeddingGroovyShell, GroovyScriptEngineGroovyClassLoader
Best forAutomation, glue code, quick tasksLibraries, 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.groovy becomes class MyScript)
  • 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 calls run()

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

No – Script mode

Yes – Class mode

hello.groovy

Has class
declaration?

Wraps in Script subclass

Compiles as
declared class

class hello extends Script {
def run() {
// your code here
}
}

Binding object
holds variables

main() calls
run()

Compiled to
.class as-is

@Field vars →
class fields

main() creates
instance + run()

JVM Execution

Script vs Class – How Groovy Compiles .groovy Files

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 @Field when 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 GroovyScriptEngine for file-based plugin systems – it handles caching and recompilation.
  • DON’T use def in scripts when you expect the variable to be accessible from methods – use @Field or 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.

Previous in Series: Groovy Compilation Lifecycle

Next in Series: GDK (Groovy Development Kit) Overview

Related Topics You Might Like:

This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *