Intermediate 3 min · June 21, 2026

Jenkins Scripted Pipeline and Groovy: Write Production-Grade CI/CD Without the Pain

Jenkins Scripted Pipeline and Groovy: production patterns, gotchas, and when to avoid it.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
June 21, 2026
last updated
1,577
articles · all by Naren
 ● Production Incident 🔎 Debug Guide
Quick Answer

Use Scripted Pipeline when you need complex logic, dynamic stages, or error handling that Declarative Pipeline can't express. But beware: Groovy's runtime metaprogramming and Jenkins' serialization model will bite you if you don't understand CPS (Continuation Passing Style) transformation.

✦ Definition~90s read
What is Jenkins Scripted Pipeline and Groovy?

Jenkins Scripted Pipeline is a domain-specific language based on Groovy that defines build, test, and deployment workflows as code. It gives you full programmatic control over pipeline execution, but its flexibility comes with sharp edges that can burn you in production.

Think of Jenkins Scripted Pipeline as a recipe that can make decisions on the fly.
Plain-English First

Think of Jenkins Scripted Pipeline as a recipe that can make decisions on the fly. A Declarative Pipeline is like a fixed menu — you pick from what's listed. Scripted is like a chef who can improvise based on what's in the fridge. But that improvisation comes with rules: you can't use fresh ingredients that don't survive freezing, because Jenkins freezes and thaws your recipe between steps.

You've seen it: a Jenkinsfile that looks like spaghetti, fails randomly, and nobody wants to touch it. Scripted Pipeline gets blamed, but the real culprit is ignorance of how Groovy and Jenkins' execution model actually work. I've debugged pipelines at 3am where a simple each loop caused a NotSerializableException because someone used a closure that captured a non-serializable reference. This article will teach you to write Scripted Pipelines that are robust, debuggable, and won't make you the on-call hero everyone resents.

Why Scripted Pipeline Exists: When Declarative Isn't Enough

Declarative Pipeline is great for 80% of use cases: build, test, deploy in a linear flow. But the moment you need conditional stage execution based on branch name, dynamic parallel branches, or error recovery that retries with backoff, Declarative's rigid structure fights you. Scripted Pipeline gives you if/else, for loops, try/catch/finally, and full Groovy power. The trade-off? You lose the automatic error handling and visual stage view that Declarative provides. I've seen teams try to shoehorn complex logic into Declarative using script blocks — that's just Scripted Pipeline with extra steps and no benefits. Use Scripted when you need it, not because you 'might' need it later.

conditionalDeploy.groovyGROOVY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — DevOps tutorial

node {
    stage('Checkout') {
        checkout scm
    }

    stage('Build') {
        sh 'mvn clean package'
    }

    // Only deploy if on main branch
    if (env.BRANCH_NAME == 'main') {
        stage('Deploy') {
            sh 'kubectl apply -f deployment.yaml'
        }
    } else {
        echo "Skipping deploy for branch ${env.BRANCH_NAME}"
    }
}
Output
Started by user admin
[Pipeline] node
[Pipeline] stage (Checkout)
...
[Pipeline] stage (Build)
...
[Pipeline] echo
Skipping deploy for branch feature/foo
[Pipeline] End of Pipeline
Finished: SUCCESS
Production Trap: Global Variable Serialization
Never assign large objects to global variables (like def data = readJSON(...) outside a node block). Jenkins serializes the entire script state between stages. If data is huge, you'll get OOM or NotSerializableException. Always scope variables inside node blocks.

The CPS Transformation: Why Your Groovy Code Behaves Differently

Jenkins doesn't run your Groovy code directly. It transforms it using Continuation Passing Style (CPS) to allow the pipeline to be paused and resumed (e.g., waiting for user input or a node). This transformation has consequences: closures can't capture non-serializable objects, loops have a limit (default 4096 iterations), and some Groovy idioms like each with method references break. The classic symptom: java.io.NotSerializableException on a closure that references this or a field. The fix: use @NonCPS annotation on methods that don't need to be resumed, or avoid capturing non-serializable references. I once spent two hours debugging a pipeline that failed only on the second run because a closure captured a File object that was created in a previous build.

nonCpsExample.groovyGROOVY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — DevOps tutorial

// This method does NOT need to be resumed, so mark @NonCPS
@NonCPS
def parseVersions(String content) {
    // Using Groovy's regex finder — safe because @NonCPS avoids serialization
    def versions = []
    content.eachLine { line ->
        def matcher = line =~ /version=(.*)/
        if (matcher) {
            versions << matcher[0][1]
        }
    }
    return versions
}

node {
    stage('Parse') {
        def content = readFile 'versions.txt'
        def versions = parseVersions(content)
        echo "Found versions: ${versions}"
    }
}
Output
[Pipeline] node
[Pipeline] stage (Parse)
[Pipeline] readFile
[Pipeline] echo
Found versions: [1.0.0, 2.0.0]
[Pipeline] End of Pipeline
Finished: SUCCESS
Senior Shortcut: @NonCPS for Pure Computations

Error Handling: Try/Catch/Finally Patterns That Actually Work

In production, pipelines fail. Network timeouts, flaky tests, insufficient disk space. Scripted Pipeline's try/catch/finally is your safety net. But there's a gotcha: if you catch an exception and don't rethrow, the stage is marked as SUCCESS even though it failed. Always rethrow or set build result explicitly. Another pattern: use retry with backoff for flaky steps. I've seen teams wrap a sh 'curl ...' in a retry(3) block without a timeout — the curl hung forever. Always combine retry with timeout. Here's a robust deployment step that retries with exponential backoff and fails fast if the cluster is down.

robustDeploy.groovyGROOVY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// io.thecodeforge — DevOps tutorial

def deployWithRetry(String namespace) {
    int maxRetries = 3
    int baseDelay = 5 // seconds
    for (int i = 0; i < maxRetries; i++) {
        try {
            timeout(time: 2, unit: 'MINUTES') {
                sh "kubectl apply -f deploy.yaml -n ${namespace}"
            }
            echo "Deploy succeeded on attempt ${i + 1}"
            return
        } catch (Exception e) {
            if (i == maxRetries - 1) {
                throw e // rethrow on last attempt
            }
            int delay = baseDelay * (int) Math.pow(2, i)
            echo "Deploy failed: ${e.message}. Retrying in ${delay}s..."
            sleep delay
        }
    }
}

node {
    stage('Deploy') {
        deployWithRetry('production')
    }
}
Output
[Pipeline] node
[Pipeline] stage (Deploy)
[Pipeline] timeout
[Pipeline] sh
kubectl apply -f deploy.yaml -n production
...
[Pipeline] echo
Deploy succeeded on attempt 1
[Pipeline] End of Pipeline
Finished: SUCCESS
Never Do This: Catching and Swallowing
If you catch an exception and don't rethrow or set currentBuild.result = 'FAILURE', Jenkins marks the stage green. Your deployment could be broken but the pipeline shows SUCCESS. Always rethrow or explicitly fail.

Parallel Execution: Don't Just Throw Stages at It

Parallel stages speed up builds, but naive parallelism causes resource contention. Jenkins' parallel step runs branches in separate threads, but they share the same node's workspace and executors. If you have 10 parallel branches each running sh 'mvn test', you'll thrash the CPU and disk. The pattern: limit parallelism using a lock resource or use separate agents. Another gotcha: variables defined inside parallel branches are not visible outside — you must use script to assign to a global variable. Here's a pattern for running integration tests in parallel with a max of 3 concurrent runs.

parallelTests.groovyGROOVY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// io.thecodeforge — DevOps tutorial

def testResults = [:]

node {
    stage('Parallel Tests') {
        def branches = [:]
        for (int i = 0; i < 5; i++) {
            def index = i // capture for closure
            branches["test-${index}"] = {
                // Use lock to limit concurrency
                lock(resource: 'test-executor', quantity: 3) {
                    sh "echo 'Running test suite ${index}'"
                    // Simulate test result
                    testResults[index] = "PASS"
                }
            }
        }
        parallel branches
    }

    stage('Report') {
        echo "Results: ${testResults}"
    }
}
Output
[Pipeline] node
[Pipeline] stage (Parallel Tests)
[Pipeline] lock
[Pipeline] { (test-0)
[Pipeline] sh
Running test suite 0
[Pipeline] lock
[Pipeline] { (test-1)
...
[Pipeline] stage (Report)
[Pipeline] echo
Results: [0:PASS, 1:PASS, 2:PASS, 3:PASS, 4:PASS]
[Pipeline] End of Pipeline
Finished: SUCCESS
Interview Gold: Parallel Variable Capture
In parallel branches, you must capture loop variables in a local variable (like def index = i) because closures close over variables, not values. Without capture, all branches see the final value of i.

Shared Libraries: Don't Repeat Yourself Across Pipelines

When you have multiple repos with similar pipelines, copy-paste is a maintenance nightmare. Jenkins Shared Libraries let you define reusable Groovy code in a separate repo and load it into any pipeline. The key: version your library with tags, and reference a specific version in your Jenkinsfile. Never use @Library('my-lib@master') — that's a moving target that will break your pipelines. I've seen a team's Friday deploy fail because someone pushed a breaking change to master. Use @Library('my-lib@v1.2.3') instead. Also, shared library code runs outside the CPS sandbox by default, so you can use full Groovy — but you must mark methods that call pipeline steps with @NonCPS or use steps object.

vars/deploy.groovyGROOVY
1
2
3
4
5
6
7
8
9
10
11
// io.thecodeforge — DevOps tutorial
// vars/deploy.groovy in shared library repo

def call(String environment) {
    // This method is a global variable 'deploy' in pipelines
    node {
        stage("Deploy to ${environment}") {
            sh "kubectl apply -f deploy-${environment}.yaml"
        }
    }
}
Output
// In Jenkinsfile:
@Library('my-lib@v1.2.3') _
deploy('staging')
Production Trap: Library Version Pinning
Always pin your shared library to a tag or commit hash. Using @master means any push to master immediately affects all pipelines using that library. Pin to @v1.2.3 and update deliberately.

When Not to Use Scripted Pipeline

Scripted Pipeline is powerful, but it's not always the right tool. If your pipeline is a simple linear sequence of build, test, deploy with no conditionals, use Declarative. It's easier to read, has better error messages, and the Blue Ocean UI renders stages better. Also, if your team is not comfortable with Groovy, Scripted will be a source of bugs. I've seen teams write 500-line Scripted pipelines that could be 50 lines of Declarative with a few script blocks. Another case: if you need to integrate with external systems via REST APIs, consider using a dedicated tool like Jenkins Configuration as Code or a separate orchestration service. Scripted Pipeline is not a general-purpose programming environment — it's a CI/CD DSL. Don't try to build a microservice in it.

Senior Shortcut: Hybrid Approach
Start with Declarative. When you hit a wall, drop into a script block for the complex logic. This gives you the best of both worlds: Declarative's structure and Scripted's power where needed. Most pipelines only need 1-2 script blocks.
● Production incidentPOST-MORTEMseverity: high

The 4GB Heap That Wasn't Enough

Symptom
Pipeline ran fine for weeks, then started throwing OutOfMemoryError: Java heap space on a simple readFile step.
Assumption
We assumed the file was too large. But it was only 50MB.
Root cause
Groovy's readFile returns a String, which is fine. But the pipeline also loaded a huge JSON config via readJSON and stored it in a global variable. Jenkins serializes the entire script state between stages, including that variable, causing heap bloat. The readJSON result was a Map with deep nesting, and serialization created a massive object graph.
Fix
Move large data reads inside node blocks and scope variables locally. Use @NonCPS on methods that process data to avoid serialization of intermediate state. Set -Xmx2g for the Jenkins agent, but more importantly, refactor to not hold large objects across stage boundaries.
Key lesson
  • Anything you assign to a global variable in a pipeline gets serialized between stages.
  • Keep state small and local.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Pipeline fails with java.io.NotSerializableException
Fix
1. Identify the closure that captures a non-serializable object (look for this, owner, or custom classes). 2. Mark the enclosing method with @NonCPS if it doesn't call pipeline steps. 3. Or refactor to avoid capturing the object (e.g., pass as parameter).
Symptom · 02
Pipeline hangs with no output
Fix
1. Check if there's an input step without timeout. 2. Check if a node block is waiting for an executor — verify agent availability. 3. Check for infinite loops in Groovy code — add echo statements to trace execution.
Symptom · 03
java.lang.StackOverflowError in pipeline
Fix
1. Likely caused by recursive CPS transformation. 2. Mark the recursive method with @NonCPS. 3. Or rewrite iteratively.
Feature / AspectScripted PipelineDeclarative Pipeline
SyntaxGroovy DSL, full programming constructsStructured YAML-like DSL
Error handlingtry/catch/finally, full controlpost sections, limited
Parallel executionFull control with parallel stepDeclarative parallel, less flexible
Serialization issuesCommon, must manage CPSRare, handled automatically
Learning curveSteeper, requires Groovy knowledgeGentler, intuitive
Best forComplex, dynamic pipelinesSimple, linear pipelines

Key takeaways

1
Scripted Pipeline gives you full control but requires understanding CPS serialization
always scope variables locally and use @NonCPS for pure computations.
2
Always combine retry with timeout to avoid hanging pipelines. Never catch exceptions without rethrowing or setting build result.
3
Use Shared Libraries with version pinning to avoid breaking pipelines on library updates.
4
Start with Declarative, drop into script blocks for complex logic
don't go full Scripted unless you absolutely need it.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between Scripted and Declarative Pipeline in Jenkins?
02
How do I fix java.io.NotSerializableException in Jenkins Pipeline?
03
How do I run parallel stages in Jenkins Scripted Pipeline?
04
Can I use Groovy closures in Jenkins Pipeline?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
June 21, 2026
last updated
1,577
articles · all by Naren
🔥

That's Jenkins. Mark it forged?

3 min read · try the examples if you haven't

Previous
Jenkinsfile: Declarative Pipeline
8 / 23 · Jenkins
Next
Jenkins Pipeline Stages and Parallel Execution