A build matrix runs the same job definition across a set of variable combinations — Node versions, operating systems, database backends — without copy-pasting the job. It's one of the more underused features in GitHub Actions, partly because the failure modes (one flaky combination blocking the whole matrix, ballooning minutes bills) aren't obvious until they happen.
The basic matrix syntax
strategy.matrix takes one or more variables, each with a list of values, and GitHub Actions runs the job once per combination — the Cartesian product of all the lists. A matrix with 3 Node versions and 2 operating systems produces 6 job runs, each with its own log and pass/fail status in the checks UI.
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
include and exclude for shaping the matrix
The full Cartesian product isn't always what you want — some combinations are redundant or invalid. exclude removes specific combinations from the generated set. include does two things: it can add extra standalone combinations not covered by the base lists, and when it matches an existing combination's keys, it adds extra variables to just that entry — useful for marking one combination as the "primary" one that also uploads a coverage report, without duplicating the whole matrix.
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
exclude:
- node-version: 18
os: windows-latest
include:
- node-version: 20
os: ubuntu-latest
upload-coverage: true
fail-fast and max-parallel
fail-fast defaults to true, which cancels all other in-progress matrix jobs the moment one fails — good for saving minutes on a clearly broken change, bad when you actually want to see results across every combination (e.g., "does this fail on Windows specifically or everywhere"). Set fail-fast: false when the point of the matrix is diagnostic breadth. max-parallel caps how many matrix jobs run concurrently, which matters on self-hosted runners with limited capacity or when you're intentionally throttling load against a shared resource like a test database.
If a matrix job exists specifically to answer "which combinations break," fail-fast cancelling the rest of the matrix on the first failure defeats the purpose. Set fail-fast: false so every combination reports its own result.
Matrix jobs and minutes cost
Every matrix combination is a full job — full runner boot, full checkout, full dependency install — and GitHub Actions minutes billing (for private repos beyond the free tier) counts each one. A 4x4 matrix isn't 4x or 4x the work of a single job in wall-clock time if they run in parallel, but it is roughly 16x the billed minutes. Keep the matrix scoped to combinations that genuinely need independent verification — testing against every patch version of a runtime rarely finds bugs that testing the majors and the oldest-supported version won't.
If you use branch protection with required status checks, each matrix combination shows up as its own named check. Adding or removing matrix values changes which checks exist, which can silently break required-check configuration on a protected branch if it isn't updated alongside the matrix.
Dynamic matrices generated by a previous job
The matrix values don't have to be hardcoded — a prior job can compute a JSON array (say, the list of changed packages in a monorepo) and expose it as a job output, which a downstream job consumes via fromJSON() in its matrix definition. This is how monorepo pipelines build a test matrix that only includes the packages actually affected by a given push, instead of testing everything on every change.
Wrapping up
Matrix strategy earns its keep when you genuinely need to verify the same logic across multiple real-world variants — runtime versions, OSes, dependency versions. Keep the matrix as small as the actual support surface requires, set fail-fast deliberately rather than by default, and remember every added dimension multiplies billed minutes, not just coverage.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.