Taming the Combinatorial Explosion: Mastering GitHub Actions Matrix Strategies
Stop duplicating your GitHub Actions jobs. Learn how to use Matrix Strategies to test across multiple OS and runtime versions efficiently using include, exclude, and fail-fast settings.
03 May 2026, 16:37 UTC

The Multi-Version Testing Headache
Maintaining a library that supports three different operating systems and four versions of a runtime (like Node.js or Python) usually leads to one of two bad outcomes: a YAML file with twelve nearly identical job definitions, or a single job that only tests the latest version and hopes for the best. The former is a maintenance nightmare; the second is a recipe for production regressions.
The solution is the strategy: matrix configuration. Instead of duplicating jobs, a matrix allows you to define a set of variables that GitHub Actions uses to spawn multiple parallel jobs. The takeaway is simple: use matrices to ensure cross-platform compatibility without bloating your workflow file.
Defining the Cartesian Product
At its simplest, a matrix creates a Cartesian product—every possible combination of the provided lists. If you define os: [ubuntu-latest, windows-latest] and node-version: [18, 20], GitHub spawns four jobs.
These values are injected into the matrix context, allowing you to use them in your steps. For example, ${{ matrix.os }} tells the runner which image to boot, and ${{ matrix.node-version }} tells the setup action which runtime to install.
Precision Control with Include and Exclude
Real-world dependencies are rarely a perfect grid. You might find that a specific feature only works on Linux, or that an older runtime version is incompatible with a newer OS. This is where include and exclude become critical.
- Exclude: Removes specific combinations that you know will fail, preventing wasted billed minutes and "false alarm" red builds.
- Include: Adds specific combinations that don't fit the general pattern, or adds extra variables (like a specific environment variable) to a single leg of the matrix.
Worked Example: Cross-Platform Runtime Validation
The following configuration tests a project across Ubuntu and Windows, using Node.js 18 and 20. It excludes Node 18 on Windows because of a known compatibility issue and adds a specific "experimental" test run for Node 22 on Ubuntu.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20]
exclude:
- os: windows-latest
node-version: 18
include:
- os: ubuntu-latest
node-version: 22
experimental: true
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
- name: Run Experimental Suite
if: matrix.experimental == true
run: npm run test:experimental
Execution Details: Run this by committing the file to .github/workflows/test.yml. You will need write permissions to the repository. The expected result is four jobs: (Ubuntu/18), (Ubuntu/20), (Ubuntu/22), and (Windows/20).
Managing the Blast Radius
While powerful, matrices introduce specific operational risks. The most immediate is the concurrency limit. If you define a matrix of 5 OS versions and 5 runtime versions, you are launching 25 parallel jobs. Depending on your GitHub plan, this can quickly exhaust your available runners or consume your monthly billed minutes.
Another critical setting is fail-fast. By default, GitHub cancels all in-progress matrix jobs if any single job fails. While this saves money, it is counterproductive for debugging. If Node 18 fails on Windows, you still want to know if it also fails on Ubuntu. Setting fail-fast: false ensures every combination completes, providing a full compatibility map.
Verification and Limitations
To verify your matrix is behaving as expected, navigate to the Actions tab in your repository. Click into a specific workflow run; you should see the jobs listed with their matrix values in the title (e.g., test (ubuntu-latest, 20)).
One limitation to keep in mind: matrix variables are static for the duration of the job. You cannot dynamically update a matrix value mid-job and expect it to affect other parallel legs. If you need a matrix based on a dynamic list (like a list of folders in a repo), you must use a separate job to generate a JSON array and pass it to the matrix via strategy: matrix: ${{ fromJson(needs.setup.outputs.matrix) }}.
Actionable Summary
To optimize your testing pipeline, start by mapping your required OS and runtime versions. Use exclude to prune impossible combinations and fail-fast: false to gather complete telemetry on failures. Finally, monitor your billed minutes to ensure your matrix hasn't grown beyond your budget.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.