Skip to content

Passing Data Between Jobs


Step outputs solved data flow within a single job. But real pipelines usually split work across multiple jobs — a build job, then a deploy job that depends on it via needs — and each job runs on its own separate runner, with its own separate filesystem, its own separate everything. Getting a value out of one job and into another needs one more layer on top of what you already know: promoting a step output to a job output.

Why This Needs A Separate Mechanism

Jobs don’t share memory, disk, or environment. When deploy runs needs: build, all that guarantees is ordering — deploy waits until build finishes. It does not automatically hand over any data build computed.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - id: version
        run: echo "value=2.1.0" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ steps.version.outputs.value }}"   # empty — wrong context!

Try this broken version to see the failure:

mkdir gha-job-outputs-demo && cd gha-job-outputs-demo
git init
mkdir -p .github/workflows
cat > .github/workflows/broken-job-outputs.yml << 'EOF'
name: Broken Job Output Passing
on: workflow_dispatch

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - id: version
        run: echo "value=2.1.0" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ steps.version.outputs.value }}"
EOF
git add .
git commit -m "Add broken job output passing demo"
git branch -M main

Push and trigger it — deploy’s step prints nothing useful, because steps.version doesn’t exist inside deploy’s own steps context; that step ran in a completely different job.

Fixing It With Job-Level outputs

A job can declare its own outputs: block, mapping a job-level output name to a step output from inside that same job. Downstream jobs then read it through needs.<job-id>.outputs.<name>.

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      app-version: ${{ steps.version.outputs.value }}
    steps:
      - id: version
        run: echo "value=2.1.0" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ needs.build.outputs.app-version }}"

Add the working version:

cat > .github/workflows/job-outputs.yml << 'EOF'
name: Job Output Passing Demo
on: workflow_dispatch

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      app-version: ${{ steps.version.outputs.value }}
    steps:
      - id: version
        run: echo "value=2.1.0" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ needs.build.outputs.app-version }}"
EOF
git add .
git commit -m "Add working job output passing demo"

Push and trigger it — deploy now correctly prints Deploying version 2.1.0. Two things had to line up: build had to expose app-version in its own outputs: block, and deploy had to both need that job and read needs.build.outputs.app-version.

Note

The job-level outputs: block is essentially a relabeling layer — it takes a steps.<id>.outputs.<key> value that exists only inside that job and republishes it under a job-scoped name that other jobs can reach through needs. Without this explicit relabeling step, there’s no way for a sibling job to see it at all.

Passing Multiple Values

A job can expose as many outputs as it needs — there’s no meaningful limit for typical use.

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      app-version: ${{ steps.meta.outputs.version }}
      commit-sha: ${{ steps.meta.outputs.sha }}
    steps:
      - id: meta
        run: |
          echo "version=2.1.0" >> "$GITHUB_OUTPUT"
          echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

Downstream, each is read individually: needs.build.outputs.app-version and needs.build.outputs.commit-sha.

A Realistic Three-Job Chain

name: Job Chain Demo
on: workflow_dispatch

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      should-deploy: ${{ steps.check.outputs.result }}
    steps:
      - id: check
        run: echo "result=true" >> "$GITHUB_OUTPUT"

  build:
    needs: detect
    if: needs.detect.outputs.should-deploy == 'true'
    runs-on: ubuntu-latest
    outputs:
      artifact-name: ${{ steps.pack.outputs.name }}
    steps:
      - id: pack
        run: echo "name=app-build-42" >> "$GITHUB_OUTPUT"

  deploy:
    needs: [detect, build]
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying artifact ${{ needs.build.outputs.artifact-name }}"

This mirrors a genuinely common real shape: a detect job decides whether deployment should happen at all, build only runs if it should and produces an artifact name, and deploy consumes that name. Note that deploy lists both detect and build in needs, even though it only reads outputs from build — needs controls execution order and job status dependencies, independent of which specific outputs you happen to reference.

Job Outputs Are Strings Only

Like step outputs, everything passed through job outputs: becomes a string on the receiving end — there’s no structured object or array type. If you need to pass something more complex (a list of changed services, a JSON blob of metadata), the common workaround is serializing it to a JSON string with echo and parsing it back out with a tool like jq on the receiving side.

How Real DevOps Engineers Use This

This pattern is the backbone of any pipeline that separates “figure out what to do” from “do it” — a common and genuinely good practice, since it keeps decision logic isolated and testable separately from the actual build or deploy mechanics.

A detect-changes job that inspects which parts of a monorepo changed, outputting a boolean per service, letting downstream jobs conditionally run only for the services that actually changed, is one of the most common real-world uses of job outputs.

Version numbers, computed image tags, and artifact names are the other classic case — computed once in an early job, consumed identically by every later job that needs consistency, rather than each job recomputing its own (and risking drift between them).

Clean up:

cd .. && rm -rf gha-job-outputs-demo
Last updated on