Diagnosing @CompileStatic Failures in Groovy: A Field Guide
A diagnostic guide to Groovy’s @CompileStatic: recognize compile-time and runtime failure patterns, trace them to metaprogramming, build config, or scope issues, and apply targeted fixes.
23 Mar 2026, 15:42 UTC

You add @CompileStatic to a Groovy class expecting faster, safer code — and the build immediately explodes with “Cannot find method” errors on code that ran fine yesterday. Or worse, it compiles and then throws NoSuchMethodException at runtime. This guide walks through the recognizable failure conditions, what causes each, and the ordered checks to isolate the problem before you start rewriting working code.
What @CompileStatic actually changes
@CompileStatic (from groovy.transform.CompileStatic) tells the Groovy compiler to emit statically typed bytecode, similar to Java, instead of routing calls through Groovy’s dynamic MetaClass dispatch. That means every method call, property access, and operator must resolve to a real, statically known member at compile time. Anything that depended on runtime metaprogramming — metaClass additions, methodMissing, propertyMissing, dynamic builders — stops working inside the annotated scope.
Assumption for this guide: Groovy 2.4+ (where static compilation matured) through Groovy 4.x, compiled with groovyc, Gradle, or Maven. The annotation is ignored by groovysh and uncompiled script execution, which is itself a common source of confusion.
Symptom-to-cause quick reference
| Symptom | Likely cause | First check |
|---|---|---|
| Compile error: “Cannot find matching method” | Call resolved via metaprogramming or duck typing | Is the method declared on the static type? |
| Compile error: “No such property” | Property added via metaClass or Expando | Is the property a declared field/getter? |
Runtime NoSuchMethodException | Mixed static/dynamic compilation; stub bound statically | Check which classes are actually annotated |
NullPointerException after enabling | Safe-navigation behavior differs under static typing in some constructs | Audit null handling in annotated code |
| Annotation appears to do nothing | Build plugin not passing it to the compiler, or script run via groovysh | Verify compilation path (see checks below) |
| Slower than before | Forced static compilation of reflection-heavy code | Profile; consider @CompileDynamic on hot spots |
Ordered diagnostic checks
- Confirm the annotation is being honored. If you get no behavior change at all, suspect the build. In Gradle, ensure the Groovy plugin compiles with the standard
groovycjoint compiler; in Maven, thegroovy-eclipse-compileror GMavenPlus must be configured correctly. A quick test: deliberately add a statically invalid call (e.g., call a nonexistent method on a concrete type) inside the annotated class. If the build still passes, the annotation is not being applied — fix the build before anything else. - Isolate the failing call. For each compile error, ask: is the target method or property declared on the static type of the receiver? If the receiver is typed as an interface or superclass but the method lives on a subclass, the compiler correctly rejects it. Dynamic Groovy would have found it at runtime.
- Search for metaprogramming in the call path. Grep the codebase for
metaClass,methodMissing,propertyMissing,ExpandoMetaClass, and category classes. Any of these in the annotated class’s dependencies will fail under static compilation. - Check annotation scope.
@CompileStaticon a class applies to its methods and closures, but nested classes and anonymous classes may need their own annotation. Mixed scopes produce confusing errors where one method compiles and its neighbor doesn’t. - Check for dynamic library boundaries. Libraries exposing
GroovyObjector relying on dynamic dispatch (some builders, templating engines, older Grails/GORM-style APIs) can throwClassCastExceptionorNullPointerExceptionwhen called from statically compiled code, because the dynamic contract is bypassed.
Fixes tied to findings
If the method genuinely exists on a subtype: fix the declared type or cast explicitly. Static compilation requires the compiler to see the member:
@CompileStatic
class Runner {
void go(Animal a) {
// a.bark() fails: bark() is on Dog, not Animal
if (a instanceof Dog) {
((Dog) a).bark() // compiles: type is now known
}
}
}If the call relies on metaprogramming: either move that logic out of the statically compiled class, or exempt the specific method with @CompileDynamic:
@CompileStatic
class Service {
void fastPath() { /* statically compiled */ }
@CompileDynamic
void dynamicPath() {
// metaClass tricks still work here
}
}If the annotation is silently ignored: fix the build configuration. For Gradle, no extra work is needed beyond the Groovy plugin — but if you use a custom compiler setup or run scripts via groovy/groovysh, switch to compiled classes. Verify by introducing a deliberate static error and confirming the build fails.
If performance regressed: reflection-heavy or ExpandoMetaClass-heavy code can produce slower bytecode when forced through static stubs. Profile before and after; annotate only the hot, statically expressible paths and leave dynamic code dynamic.
If a dynamic library breaks: wrap the library boundary in a @CompileDynamic adapter class, keeping the rest of your code statically compiled.
Verifying the fix
Three concrete checks confirm static compilation is working as intended:
- Compile a minimal annotated class that calls a method existing only on a subclass — the compiler must reject it. If it doesn’t, the annotation isn’t active.
- After fixing, recompile and run a unit test exercising the previously dynamic call. Success confirms the static binding is correct.
- Inspect the bytecode with
javap -c YourClass.class(run from the build output directory; needs only JDK tools). Dynamically compiled Groovy shows calls throughorg.codehaus.groovy.runtime.callsite; statically compiled code shows directinvokevirtual/invokestaticinstructions. This is the definitive check.
When to escalate
Escalate beyond local fixes when: (1) the failing pattern is pervasive across a shared library, meaning the fix is an architecture decision, not a patch; (2) a third-party dynamic library is central to the annotated code and wrapping it would be extensive; or (3) the team lacks consensus on static-vs-dynamic policy — mixed annotation without guidelines produces exactly the confusing partial failures described above. In those cases, decide on a project-wide policy (annotate everything, annotate hot paths only, or stay dynamic) before touching more code.
Limitations to keep in mind
@CompileStatic does not add null-safety checks, does not make Groovy generics identical to Java’s, and does not work in script shells. Behavior details vary slightly across Groovy 2.x, 3.x, and 4.x, so verify against your exact compiler version before rolling out broadly.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.