Why can a Jenkins Pipeline script fail with java.io.NotSerializableException, and what does @NonCPS change about a method?
answer
- builds survive a controller restart
- state is written to disk, not held on a stack
- locals must fit in the save file
- matchers and streams are the usual culprits
- the annotation opts a method out
basics
~20 sA Jenkinsfile runs under a continuation-passing-style interpreter that saves the program's state, including local variables, at every step so a build can survive a controller restart. Holding a non-serializable object across a step call breaks that save. @NonCPS runs a method as plain Groovy, outside the transform.
solid answer
~60 sA Jenkinsfile is not ordinary Groovy: the Pipeline engine rewrites it into continuation-passing style so that a running build is durable — it can be paused, and it resumes after a controller restart. Durability requires persisting the program state at each step boundary, and that state includes your local variables. If a local variable holds something that does not implement `Serializable` — a regex `Matcher`, an `InputStream`, a parsed-JSON node, a Jenkins model object — and it is still in scope when the next step runs, the build fails with `java.io.NotSerializableException` naming the offending type. The fixes are to finish with the object before the next step, null it out, convert it to plain serializable data, or move the work into a method annotated `@NonCPS`. `@NonCPS` tells the engine to execute that method as ordinary Groovy on the calling thread rather than transforming it, so its frame is never persisted — but the documented restriction is that such a method must not call pipeline steps or other CPS-transformed code, and it cannot be suspended partway.
code
groovy · 11 lines@NonCPS
def extractVersion(String text) {
def m = text =~ /version = '(.+)'/
return m.find() ? m.group(1) : null
}
node {
def text = readFile 'build.gradle'
def v = extractVersion(text)
sh "echo building ${v}"
}go deeper
Know that a Jenkinsfile runs on a special interpreter and that some Java objects cannot be held in variables across steps; recognise the exception name when you see it in a log.
Explain the chain: durable builds require the state to be saved at every step, saved state includes local variables, so a non-serializable local breaks the save. Describe what @NonCPS opts a method out of.
Debug it live — read the type out of the exception, find the variable still in scope, and choose between consuming it early, reducing it to plain data, or confining it to a @NonCPS method whose restrictions you can state.
Set the boundary: how much logic belongs in the Jenkinsfile at all, given that pipeline Groovy is slow, awkwardly transformed, and hard to test, versus in scripts or services the pipeline merely invokes.
## A Jenkinsfile is not ordinary Groovy Jenkins Pipeline builds are **durable**: a build can wait days at an approval, and if the controller is restarted mid-run the build resumes where it stopped rather than starting over. No ordinary JVM thread can do that, because a thread's stack cannot be written to disk and reloaded. So the Pipeline engine does not run your script on a normal thread. It transforms it into **continuation-passing style (CPS)** — the program is rewritten so that its execution state is an explicit data structure the engine owns rather than a JVM stack. At every step boundary that state is serialized to the build's directory on disk. That is the whole trick, and every strange Groovy behaviour in a Jenkinsfile follows from it. ## Where the exception comes from The persisted state includes your local variables. Consider: ```groovy def version = null def matcher = readFile('build.gradle') =~ /version = '(.+)'/ if (matcher.find()) { version = matcher.group(1) } sh "echo ${version}" ``` `matcher` is a `java.util.regex.Matcher`, which is not serializable. It is still in scope when `sh` runs, so the engine tries to persist it and the build dies with `java.io.NotSerializableException: java.util.regex.Matcher`. The message names the type, which is the main clue you get. The usual offenders are the same handful: regex matchers, streams and readers, objects returned by ad-hoc XML or JSON parsing, and Jenkins model objects fetched through the API. ## The four ways out 1. **Drop the reference before the next step.** Extract what you need into a `String` and set the variable to `null`. Crude, but it works and the intent is visible. 2. **Convert to plain serializable data.** Strings, numbers, lists and maps of those all persist fine. 3. **Confine it to a `@NonCPS` method** — see below. This is the idiomatic fix, because the non-serializable object never escapes a frame that is never persisted. 4. **Use a step instead of raw Java.** Where a pipeline step exists for the job — reading properties, parsing files — it returns data the engine can already persist. ## What @NonCPS does ```groovy @NonCPS def extractVersion(String text) { def m = text =~ /version = '(.+)'/ return m.find() ? m.group(1) : null } node { def text = readFile 'build.gradle' def v = extractVersion(text) // Matcher never survives the call sh "echo ${v}" } ``` Annotating a method `@NonCPS` tells the engine to skip the CPS transform for it: the method executes as ordinary compiled Groovy on the calling thread. Its local variables live on a real JVM stack and are never serialized, so non-serializable objects inside it are harmless — as long as they do not leak out through the return value. The cost is that the method is now outside the durable execution model. It runs to completion in one go and cannot be suspended partway, and the documented restriction is that it must not call pipeline steps or other CPS-transformed methods. Treat `@NonCPS` as "pure computation only": take serializable inputs, do plain Groovy or Java work, return serializable output. ## The other CPS symptoms The same transform explains other oddities candidates half-know: - **Speed.** Every transformed method call goes through the interpreter, so a tight loop over ten thousand items is dramatically slower than plain Groovy. Heavy computation belongs in a `@NonCPS` method, in a script the pipeline shells out to, or outside the pipeline entirely. - **Closures.** Iteration idioms built on closures, such as `.each { }` and `.collect { }`, interact awkwardly with the transform; classic `for` loops are the safe, conventional choice in CPS-transformed pipeline code. - **Groovy features that do not survive the rewrite.** Some language constructs behave differently or not at all once transformed, which is why a snippet that runs fine in `groovysh` can misbehave in a Jenkinsfile. ## Why this matters in an interview The question separates people who have written Jenkinsfiles from people who have debugged them. The right shape of answer is causal, not incantatory: durability requires persisting state, persisting state requires serializable locals, `NotSerializableException` is that requirement being violated, and `@NonCPS` is the sanctioned way to keep a non-serializable object out of persisted state. Someone who has only heard "add `@NonCPS` when it complains" will not be able to say why the annotation must not call steps.
- Why must a @NonCPS method avoid calling pipeline steps?Because it runs outside the durable execution model. Pipeline steps depend on the CPS engine to suspend and resume the program around them, and a @NonCPS frame is never persisted and cannot be suspended partway. The documented rule is that such a method must not call steps or other CPS-transformed code; keep it to pure computation over serializable values.
- Why is a large loop noticeably slow in a Jenkinsfile but fast in plain Groovy?Every CPS-transformed call goes through the Pipeline interpreter rather than running as compiled Groovy, so per-iteration overhead is large. Push heavy computation into a @NonCPS method, into a script the pipeline invokes with sh, or out of the pipeline altogether — the Jenkinsfile should orchestrate, not compute.
- How would you locate the offending variable when a build reports NotSerializableException?Start from the type named in the exception — it is usually a Matcher, a stream, or a parsed-document node. Then find the local variable of that type that is still in scope at the next step call. The fix is to consume it before the step, reduce it to strings or maps, or move the work into a @NonCPS method.
A Pipeline build is like a game that autosaves at every checkpoint: anything you are carrying has to fit in the save file. A regex Matcher is an object the save format cannot write down, so the autosave fails — and @NonCPS is a side room the game never tries to save.
saying these in an interview costs you the question
- Says a Jenkinsfile is just a normal Groovy script
- Adds @NonCPS everywhere as a blanket fix
- Calls pipeline steps from inside a @NonCPS method
- Thinks the error is about the sandbox or permissions
- Blames the agent rather than the persisted local variable