How Do I Use include and exclude in a Matrix?
exclude drops combinations from the matrix product and include merges keys into existing combinations or appends new ones. GitHub applies exclude first.
In a GitHub Actions matrix, exclude removes combinations from the product and include either merges extra keys into combinations that already exist or appends combinations the product never produced. GitHub applies every exclude entry first and every include entry afterwards, which is why an include entry can add back a combination that exclude just dropped (GitHub Actions workflow syntax, checked on 2026-08-13).
Answer
Read the block in the order the runner evaluates it rather than the order it appears in the file. Five behaviors cover the whole feature, and three of them belong to include.
| Key | What you write | Effect on the job set |
|---|---|---|
matrix.<name> | A list of values | The product of every list is the starting set of jobs |
exclude | A partial combination | Drops every job whose values match all keys in the entry |
include entry with no original matrix keys | An object of extra keys | Merges those keys into every combination, count unchanged |
include entry whose original keys match a combination | An object of extra keys | Merges into the matching combinations only, count unchanged |
include entry that would overwrite an original value | An object naming a new value | Appended as one new combination, count plus one |
The rule under the last two rows is a single sentence in the GitHub reference: an object is added to a combination when none of its key and value pairs overwrite an original matrix value, and an object that fits no combination becomes a new one. Values added by an earlier include entry can be overwritten by a later one, which is what makes default-then-override routing work.
Here is one matrix that uses both keys.
name: test
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
suite: [unit, contract, browser]
arch: [x64, arm64]
exclude:
- suite: browser
arch: arm64
include:
- runner: warp-ubuntu-latest-x64-4x
- arch: arm64
runner: warp-ubuntu-latest-arm64-4x
- suite: browser
runner: warp-ubuntu-latest-x64-8x
- suite: smoke
arch: x64
runner: warp-ubuntu-latest-x64-2x
steps:
- uses: actions/checkout@v4
- run: ./scripts/test.sh --suite ${{ matrix.suite }}Three suites multiplied by two architectures is six combinations. The exclude entry matches browser on arm64 and drops it, leaving five. The first include entry names no original key, so it adds runner to all five. The second matches the two arm64 cells and overwrites the added value there. The third matches the one remaining browser cell and overwrites it again. The fourth names a suite value that no dimension holds, so it cannot merge anywhere and is appended.
| suite | arch | runner | How the cell got there |
|---|---|---|---|
unit | x64 | warp-ubuntu-latest-x64-4x | Product, default runner from include entry 1 |
unit | arm64 | warp-ubuntu-latest-arm64-4x | Product, runner overwritten by include entry 2 |
contract | x64 | warp-ubuntu-latest-x64-4x | Product, default runner from include entry 1 |
contract | arm64 | warp-ubuntu-latest-arm64-4x | Product, runner overwritten by include entry 2 |
browser | x64 | warp-ubuntu-latest-x64-8x | Product, runner overwritten by include entry 3 |
smoke | x64 | warp-ubuntu-latest-x64-2x | Appended by include entry 4 |
browser | arm64 | none | Removed by exclude |
Six jobs run from a block that reads like five combinations plus some annotations.
Detail
Why an include entry sometimes adds a job
Every surprise in that table comes from one test: can this object be merged without changing a value the product already fixed. { arch: arm64, runner: ... } passes for the two arm64 cells because arch already holds arm64 there and runner is new. { suite: smoke, arch: x64, runner: ... } fails everywhere because no cell has suite set to smoke, so it lands as a sixth job.
Two habits keep the count predictable. Write the include entries that add keys before the ones meant to append combinations, since reading order then matches evaluation order. And keep appended entries complete: an appended combination only carries the keys its own object names, so a step that reads ${{ matrix.arch }} gets an empty string when the appended entry forgot that key.
The ceiling is fixed. A matrix generates a maximum of 256 jobs per workflow run, counted after exclude and include are applied (GitHub Actions limits, checked on 2026-08-13). A generated dimension that grows past that fails the run instead of truncating, which the matrix explosion guide covers.
Routing each combination to a runner label
runs-on reads a matrix value like any other expression, so include is the cleanest place to attach a machine to a combination. The pattern above sets one default label for the whole grid and then overrides the cells that need something else, which keeps the dimensions describing work and the include block describing hardware.
| Cell | Runner label | Shape |
|---|---|---|
| Default for every combination | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB, 150GB SSD |
Every arm64 cell | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB, 150GB SSD |
The browser cell | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB, 150GB SSD |
The appended smoke cell | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB, 150GB SSD |
Shapes come from the cloud runners documentation. The same pattern crosses platforms: an include entry can point one cell at warp-macos-15-arm64-6x and another at warp-windows-latest-x64-4x while the steps stay identical. Sizing each cell separately is one of the levers in the guide to speeding up GitHub Actions, and the matrix builds guide works through how wide the grid should be before you tune the labels.
Printing the resolved matrix before the jobs run
Reason about the block once, then confirm it from the run. Add a step that prints the resolved values for the cell it runs in.
- name: show this cell
run: |
echo 'matrix: ${{ toJSON(matrix) }}'
echo 'cell ${{ strategy.job-index }} of ${{ strategy.job-total }}'toJSON(matrix) prints every key the cell actually received, including the ones include merged in, and strategy.job-total prints the size of the resolved set (GitHub Actions contexts, checked on 2026-08-13). A cell whose runner key prints empty explains a job that queues forever, and a job-total that differs from your count points straight at an include entry that appended instead of merging.
After the run, the Jobs report aggregates run count and duration percentiles per unique repository, workflow, and job name combination (reports documentation), so a cell that was supposed to disappear shows up as a row that kept collecting runs.
What the edit costs or saves
Both keys move billed minutes. Assume the six cells above run at these durations.
| Cell | Duration | Billed minutes per run |
|---|---|---|
unit + x64 | 5 minutes | 5 |
unit + arm64 | 5 minutes | 5 |
contract + x64 | 4 minutes | 4 |
contract + arm64 | 4 minutes | 4 |
browser + x64 | 9 minutes | 9 |
smoke + x64 | 2 minutes | 2 |
| Total | wall clock 9 minutes | 29 |
The single exclude entry keeps a seventh 9 minute cell out of every run, which is 4,500 minutes a month at 500 pull request runs.
Related Questions
Does include run before or after exclude in a GitHub Actions matrix?
GitHub processes every exclude entry first and every include entry afterwards, so an include entry can add back a combination that exclude removed. Count the product, subtract the exclusions, then apply the inclusions in the order they appear in the file. The build matrix entry covers the surrounding keys, including fail-fast and max-parallel.
Why did my include entry create an extra job instead of adding a key?
An include object is merged into a combination only when none of its keys overwrite an original matrix value. An entry that names a value no dimension holds cannot merge into any combination, so it is appended as one new job. Print ${{ toJSON(matrix) }} in a step to see which keys each cell received, and check strategy.job-total against your own count.
How do I send each matrix combination to a different runner label?
Put a runner key in the include entries and set runs-on: ${{ matrix.runner }}. A bare include object with no original matrix keys sets the default label for every combination, and a later entry that matches a subset overwrites that added value for those cells only. Labels and shapes are listed in the cloud runners documentation, and the matrix explosion guide covers what to do when the grid itself is the problem.
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.