Control Flow: when, parallel and matrix

Running a stage only when it should run, shortening the critical path with parallel branches, and generating a build matrix instead of copying stages.

intermediate 20 min lesson hands-on task included

Three constructs decide whether a pipeline is a straight line that always runs everything, or something that adapts to the branch it is on and finishes in a third of the time.


Topic 1: when — Running a Stage Only When It Matters

stage('Deploy to staging') {
    when {
        branch 'main'
        not { changeRequest() }
    }
    steps { sh './deploy.sh staging' }
}

Conditions are ANDed by default. The ones worth knowing:

when { branch 'main' }
when { branch pattern: 'release/.*', comparator: 'REGEXP' }
when { changeRequest() }                                  // any PR
when { changeRequest target: 'main' }                     // PR targeting main
when { tag 'v*' }
when { environment name: 'DEPLOY', value: 'true' }
when { expression { params.ENVIRONMENT == 'production' } }
when { changeset '**/*.sql' }                             // files touched
when { equals expected: 2, actual: currentBuild.number }
when { anyOf { branch 'main'; branch 'develop' } }
when { allOf { branch 'main'; expression { params.DEPLOY } } }
when { not { branch 'main' } }
when { buildingTag() }

beforeAgent true is the option that saves real time and money:

stage('Deploy') {
    when {
        beforeAgent true
        branch 'main'
    }
    agent { label 'deploy' }
    steps { sh './deploy.sh' }
}

Without it, Jenkins allocates the agent, checks out the repository, then evaluates the condition and skips the stage. With it, the condition is evaluated first and no agent is claimed. On a pipeline with several environment-specific stages this is the difference between provisioning five agents and one.

Its siblings, beforeInput and beforeOptions, work the same way for the other two costs.


Topic 2: parallel

PARALLEL SHORTENS THE PIPELINE — MATRIX WRITES IT FOR YOU parallel — named branches, same stage build unit integration lint deploy All branches must finish before the stage completes. failFast true — stop the rest as soon as one fails matrix — one definition, N combinations axes: os = [linux, windows] · jdk = [17, 21] linux · jdk17 linux · jdk21 windows · jdk17 windows · jdk21 excludes removes combinations you do not support. WHAT PARALLEL ACTUALLY BUYS Wall-clock time, if you have executors free. On a saturated controller it buys queueing. THE TRAP Parallel branches sharing one workspace overwrite each other. Give each an agent, or a subdirectory. KEEP THE CRITICAL PATH SHORT The pipeline is as slow as its slowest parallel branch. Measure per-stage duration before adding parallelism — the fix is often one slow test.
Parallel shortens wall-clock time when executors are free; matrix writes the combinations for you. The two panels at the bottom are the constraints that decide whether either actually helps.
stage('Verify') {
    parallel {
        stage('Unit') {
            agent { label 'linux' }
            steps { sh 'make test-unit' }
            post { always { junit 'reports/unit/*.xml' } }
        }
        stage('Integration') {
            agent { label 'linux' }
            steps { sh 'make test-integration' }
        }
        stage('Lint & static analysis') {
            agent { label 'linux' }
            steps { sh 'make lint' }
        }
    }
}

Properties to plan around:

  • All branches must finish before the parent stage completes. The stage takes as long as its slowest branch.
  • failFast true aborts the remaining branches as soon as one fails. Good for fast feedback; bad when you wanted the full picture of what is broken. Choose per stage rather than globally.
  • Each branch needs its own agent, or its own directory. Two branches sharing one workspace overwrite each other’s files, and the resulting failures look random.
  • Parallelism buys nothing without free executors. On a saturated controller, four parallel branches queue and the pipeline gets slower because of the scheduling overhead.

For dynamic parallelism — one branch per item in a list — you need script:

script {
    def regions = ['eu-west-1', 'us-east-1', 'ap-south-1']
    parallel regions.collectEntries { r ->
        ["deploy-${r}": { sh "./deploy.sh --region ${r}" }]
    }
}

Topic 3: matrix

Matrix generates the combinations that you would otherwise copy and paste:

stage('Cross-platform tests') {
    matrix {
        axes {
            axis { name 'PLATFORM'; values 'linux', 'windows' }
            axis { name 'JDK';      values '17', '21' }
        }
        excludes {
            exclude {
                axis { name 'PLATFORM'; values 'windows' }
                axis { name 'JDK';      values '17' }
            }
        }
        agent { label "${PLATFORM}" }
        stages {
            stage('Test') {
                steps { sh "./test.sh --jdk ${JDK}" }
            }
        }
        post {
            always { junit "reports/${PLATFORM}-${JDK}/*.xml" }
        }
    }
}

Two axes of two values each is four cells; the excludes block removes one, leaving three. Cells run in parallel by default, so a matrix can consume a lot of executors quickly — options { throttle(…) } or a smaller axis is the answer when it saturates the controller.

Matrix is for genuinely identical work across variants. When the cells start needing when conditions and different steps, they are not a matrix; they are separate stages that happen to look similar.


Topic 4: Failing Deliberately

// Fail the build
error 'Deployment target not configured'

// Mark unstable but keep going
unstable 'Integration tests failed — investigating'

// Capture an exit code instead of failing immediately
script {
    def rc = sh(script: './flaky-check.sh', returnStatus: true)
    if (rc != 0) { unstable("check exited ${rc}") }
}

// Contain a failure and keep the pipeline going
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
    sh './optional-report.sh'
}

// Retry transient work — network, registry, flaky infra
retry(3) {
    sh 'curl -fsSL https://artifacts.example/api/deploy'
}

// Bound anything that can hang
timeout(time: 10, unit: 'MINUTES') {
    sh './integration-tests.sh'
}

retry belongs on infrastructure operations, not on tests. Retrying a flaky test hides the flakiness and makes the suite slower and less trustworthy; retrying a registry push that failed on a 503 is exactly right. Deciding which one you are doing is the whole judgement.

catchError with buildResult: 'UNSTABLE' is how an optional stage — a report, a nice-to-have scan — fails visibly without blocking a release.


Topic 5: Sequential Stages Inside Parallel

Sometimes a parallel branch needs several ordered steps of its own:

stage('Environments') {
    parallel {
        stage('EU') {
            stages {
                stage('Deploy EU')  { steps { sh './deploy.sh eu' } }
                stage('Smoke EU')   { steps { sh './smoke.sh eu' } }
            }
        }
        stage('US') {
            stages {
                stage('Deploy US')  { steps { sh './deploy.sh us' } }
                stage('Smoke US')   { steps { sh './smoke.sh us' } }
            }
        }
    }
}

Each region deploys and verifies in order, and the two regions run concurrently. This is the shape most multi-region deployments want, and it renders correctly in the stage view — which matters when someone is watching a release.


Topic 6: Making the Pipeline Faster, in Order

Measure first: the stage view and currentBuild.duration per stage tell you where the time is. Then, roughly in order of return:

1. Delete work that does not need to run on every commit.
   Nightly is a legitimate schedule for a 20-minute security scan.

2. beforeAgent true on every conditional stage.
   Stops agent allocation for stages that will be skipped.

3. Cache dependencies. A mounted ~/.m2, ~/.npm or a build cache
   often halves the build stage on its own.

4. Parallelise the longest independent stages — after 1–3, because
   parallelising waste just wastes it concurrently.

5. Shrink the slowest test. One test is usually 30% of the suite.

6. Use a bigger agent for the one stage that needs it, rather than
   sizing every agent for the worst case.

The number to watch is time-to-first-failure, not total duration. A pipeline that tells a developer they broke something in 90 seconds is more valuable than one that takes 25 minutes to give a complete report.

Try it yourself: run three test suites sequentially, then in parallel, and record both durations. Then run the parallel version on a busy controller with no free executors and record it again. The third number is the one that decides whether parallelism is your bottleneck or your fix.

Common mistake: adding retry(3) around a test stage to make a flaky suite green. The build passes, the flakiness stays, the suite takes three times as long on its bad days, and everyone stops believing the pipeline. Quarantine the flaky test, fix it or delete it — those are the three honest options.