Build Matrix
A build matrix expands one job definition into many jobs, one per combination of the values you list. How matrix keys, include, exclude and fail-fast behave.
A build matrix is a workflow construct that expands one job definition into many jobs, one for each combination of the variables you list. In GitHub Actions it is written as a strategy.matrix block on a single job, and every combination it produces runs as an independent job with its own runner, its own workspace, and its own log.
The construct predates GitHub Actions and shows up in most workflow systems under names such as matrix, axes, or fan-out. The spelling below is the GitHub Actions one, because that is where most engineers meet the idea first.
Definition
Each key under strategy.matrix declares one variable and holds a list of values. The workflow expands the job into the Cartesian product of those lists, so a matrix with a list of three values and a list of four values produces twelve jobs from a single block of YAML.
Three words cover most conversations about matrices. A dimension, sometimes called an axis, is one key and its list. A combination is one selection of a value from every dimension, and it becomes one job. Expansion is the moment the workflow turns the definition into that set of jobs, which happens when the run starts.
Every expanded job receives its own values through the matrix context. A job reads them as ${{ matrix.os }} or ${{ matrix.node }}, and those expressions are valid in runs-on, in step inputs, in env, and in if conditions. That is the whole mechanism: one definition, one context lookup per job, many machines.
Expanded jobs are independent. They do not share a filesystem, a step sequence, or a process. Anything one combination needs from another has to travel through an artifact, a cache entry, or a job output. The GitHub Actions run view names each expanded job with the job id followed by the combination values in parentheses, so test (ubuntu-latest, 20) is the display name of the job for the ubuntu-latest and 20 pair. That naming has a practical consequence: required status checks are matched by name, so changing a matrix value renames the check and a branch protection rule that referenced the old name stops matching.
The strategy keys
Five keys shape a matrix. The behavior below is from the GitHub Actions workflow syntax reference, checked on 2026-08-13.
| Key | What it does | Default |
|---|---|---|
matrix.<name> | Declares one dimension and its list of values. The product of all dimensions is the job set. | none |
include | List of objects that either add keys to matching combinations or append new combinations. | empty |
exclude | List of partial combinations to remove from the product. | empty |
fail-fast | Cancels the other in-progress jobs in the matrix when one job fails. | true |
max-parallel | Ceiling on how many jobs from this matrix run at the same time. | unset, so runner availability decides |
How include and exclude change the job set
exclude is the simpler of the two. Each entry is a partial combination, and any expanded job whose values match every key in that entry is dropped from the set.
include has two behaviors that depend on the object it holds. When the object's keys match an existing combination and add no conflicting value to the original dimensions, its extra key and value pairs are merged into that combination, and the job count stays the same. When the object describes a combination the product never produced, it is appended as one new job.
Order matters when both keys appear. GitHub processes exclude first and include afterwards, which means an include entry can add back a combination that exclude removed. Reading a matrix from top to bottom in the file gives the wrong answer for that case, so count the product first, subtract the exclusions, then apply the inclusions.
fail-fast and what cancellation costs
fail-fast defaults to true. The first matrix job that reports a failure cancels the other jobs from the same matrix that are already running, and jobs from that matrix still waiting in the queue never start.
That default suits a matrix used as a gate, where one broken combination is enough to block the merge and the remaining jobs would only spend minutes confirming it. It works against a matrix used as a compatibility report, where the question is which of the twelve combinations passed. In that second case one early failure leaves you with a run full of cancelled jobs and no picture of the rest, so set fail-fast: false and read the whole grid.
Matrix size and available capacity
Expansion is bounded by a documented limit. A matrix generates a maximum of 256 jobs per workflow run, and the limit applies to GitHub-hosted and self-hosted runners alike (GitHub Actions usage limits, checked on 2026-08-13). A product that grows past that number fails the run rather than truncating the set, so wide matrices built from generated lists need a guard on the list length.
Execution is bounded by something else. Expansion creates every job at the same instant, and each job then waits for an online runner that advertises every label in its runs-on key. A matrix of 24 jobs facing 6 eligible runners runs in 4 waves, so its wall clock time is roughly four job durations plus queue time, and doubling the matrix width without adding runners doubles the wall clock rather than the parallelism.
Total consumption behaves differently again. Those 24 jobs at 6 minutes each are 144 job minutes whichever way the waves fall. Parallelism moves when the minutes are spent, and the matrix width decides how many minutes exist at all. This is the arithmetic to run before adding a fourth dimension to a matrix that already has three.
max-parallel narrows the set deliberately. It is the right key when the constraint sits outside the runner fleet: a rate-limited external API, a fixed number of license seats, or a shared test database that cannot take 24 concurrent connections. Setting it below the fleet size trades wall clock for a constraint you do not control.
Two more capacity notes are worth carrying. Matrix jobs count against whatever concurrency ceiling applies to the account or fleet they run on, which the concurrency limit entry covers. And a job that never finds a matching runner sits in the queue rather than failing, so a label typo in one dimension of a matrix shows up as one permanently pending job next to a row of green ones.
Example
This workflow tests a Node project across three operating systems and three Node versions.
name: test
on:
push:
branches: [main]
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [20, 22, 24]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm testThree operating systems multiplied by three Node versions gives nine jobs from one job definition. The run view lists them as follows.
| Job name | matrix.os | matrix.node | Runner selected by runs-on |
|---|---|---|---|
test (ubuntu-latest, 20) | ubuntu-latest | 20 | GitHub-hosted Linux |
test (ubuntu-latest, 22) | ubuntu-latest | 22 | GitHub-hosted Linux |
test (ubuntu-latest, 24) | ubuntu-latest | 24 | GitHub-hosted Linux |
test (macos-latest, 20) | macos-latest | 20 | GitHub-hosted macOS |
test (macos-latest, 22) | macos-latest | 22 | GitHub-hosted macOS |
test (macos-latest, 24) | macos-latest | 24 | GitHub-hosted macOS |
test (windows-latest, 20) | windows-latest | 20 | GitHub-hosted Windows |
test (windows-latest, 22) | windows-latest | 22 | GitHub-hosted Windows |
test (windows-latest, 24) | windows-latest | 24 | GitHub-hosted Windows |
Nine checkouts, nine npm ci runs, nine test runs. The steps were written once.
Counting a matrix that uses include and exclude
Adding both keys to the same block is where job counts start surprising people.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [20, 22, 24]
exclude:
- os: windows-latest
node: 20
include:
- os: ubuntu-latest
node: 24
coverage: true
- os: ubuntu-latest
node: 25Work through it in the order GitHub applies the keys. The product is nine jobs. The exclude entry matches test (windows-latest, 20) and drops it, leaving eight. The first include entry matches the existing ubuntu-latest and 24 combination and merges coverage: true into it, so that job can branch on ${{ matrix.coverage }} while the count stays at eight. The second include entry names a Node version that appears in no dimension, so it is appended as a new job. The run holds nine jobs, one of which carries a key the other eight do not have.
Pointing the same matrix at specific runner labels
runs-on reads a matrix value like any other expression, so a dimension can hold runner labels instead of operating system names. That form puts the machine choice in the matrix and leaves the steps untouched.
jobs:
test:
strategy:
fail-fast: false
matrix:
runner:
- warp-ubuntu-latest-x64-4x
- warp-ubuntu-latest-arm64-4x
- warp-macos-15-arm64-6x
- warp-windows-latest-x64-4x
node: [22, 24]
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm testFour labels multiplied by two Node versions gives eight jobs. Each label is a routing key that selects one machine shape, the same way ubuntu-latest or macos-latest does in the first example, and whoever operates the fleet publishes the mapping from label to machine, which the runner label entry covers in full. Because the matrix value lands in the job name, the run view shows test (warp-ubuntu-latest-arm64-4x, 22) and a reader can tell which machine each job used without opening the log.
Building the matrix at run time
A dimension can also be a JSON array produced by an earlier job, which is how a monorepo fans out over only the packages a commit touched.
jobs:
plan:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.list.outputs.targets }}
steps:
- uses: actions/checkout@v4
- id: list
run: echo "targets=$(./scripts/changed-packages.sh)" >> "$GITHUB_OUTPUT"
build:
needs: plan
strategy:
matrix:
package: ${{ fromJSON(needs.plan.outputs.targets) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make build PACKAGE=${{ matrix.package }}The job count is now decided at run time by whatever array changed-packages.sh prints. A commit touching two packages expands build into two jobs, and a commit touching thirty expands it into thirty. One edge case is worth knowing before shipping this pattern: an empty array expands into zero jobs and GitHub marks build as skipped, so a downstream job that gates on build succeeding needs an if condition that treats the skipped result the way you intend.
Related Terms
- How to structure matrix builds in a GitHub Actions workflow: patterns for splitting test suites across a matrix and keeping the job count under control.
- Concurrency limit: the ceiling that decides how many matrix jobs run at once and how the rest queue.
- Runner label: how a value in
runs-onselects a machine, including labels held in a matrix dimension. - Diagnosing and reducing GitHub Actions queue times: what to measure when a wide matrix spends longer waiting than running.
- WarpBuild cloud runners documentation: the full label catalog with operating systems, architectures, and machine sizes.
- WarpBuild reports documentation: per label queue time and duration data, for measuring how many waves a wide matrix ran in.
- WarpBuild pricing: per minute rates by runner type, which is the input to any job minute arithmetic across a matrix.
FAQ
What is a build matrix in GitHub Actions?
A build matrix is the strategy.matrix block on a job. Each key names a variable and holds a list of values, and the workflow expands that one job definition into a separate job for every combination of those values. Nine combinations become nine independent jobs, each with its own runner, its own workspace, and its own log.
How many jobs does a matrix create?
Multiply the lengths of every list in the matrix block. Two operating systems and three language versions give six jobs. Each exclude entry that matches a combination removes one job, and each include entry that matches no existing combination adds one. GitHub caps a matrix at 256 jobs per workflow run.
What does fail-fast do in a matrix?
fail-fast defaults to true, so the first matrix job that fails cancels the other in-progress jobs in the same matrix. Set strategy.fail-fast to false when the point of the run is the full pass and fail picture across every combination, because the default hides later failures behind the first one.
Why do matrix jobs queue instead of all starting at once?
Expansion and execution are separate steps. The matrix creates every job immediately, then each job waits for an online runner that advertises every label in its runs-on key. A matrix wider than the number of eligible runners runs in waves, so wall clock time tracks available capacity instead of matrix width.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.