GitHub Actions matrix exclude not working, and the partial match
GitHub Actions matrix exclude not working is most often the opposite complaint from the one people arrive with: the entry matched more combinations than intended, not fewer. An exclude entry is a partial match, so naming one key removes every combination that agrees on that key, whatever the other keys hold.

What this error means
The job count is wrong and nothing is annotated. Either several legs you wanted are gone, which is the partial match doing exactly what it is specified to do, or a leg you tried to remove is still there, which usually means it was not in the cross product to begin with. The tell in both directions is that the run succeeds and the job list is simply a different length than the arithmetic in your head. There is no message to search for, because nothing went wrong from the builder point of view.
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
exclude:
- node: 18
# two jobs remain, not threeAn exclude entry constrains only the keys it names
The published schema says it in one line: an excluded configuration only has to be a partial match for it to be excluded. The builder implements that literally. When it reads an exclude entry it walks the structure and creates one equality comparison for every leaf value in it, so an entry with one key produces one comparison and an entry with three produces three.
A combination from the cross product is then tested against those comparisons, and it is excluded if every one of them holds. Keys that the entry does not mention are not compared at all, because no comparison was created for them. They are free to be anything.
The consequence is the arithmetic people get wrong. Against a matrix of two operating systems and two node versions, an entry naming only the node version removes both combinations carrying that version, which is half the matrix rather than one job. To remove exactly one job, the entry has to name enough keys to identify it, which usually means all of them.
This also explains the reverse complaint. An entry that names more keys than it needs to is a narrower filter, and if any one of those values does not appear in the cross product, nothing matches and nothing is removed. A leg that stubbornly survives is often being described by an entry that no longer quite matches it after somebody changed a value elsewhere in the matrix.
| Exclude entry against os and node | Comparisons created | Combinations removed |
|---|---|---|
node: 18 | one | both, on every operating system |
os: windows-latest | one | both, on every node version |
os: windows-latest and node: 18 | two | exactly one |
| A value not in the cross product | one or more | none, nothing matches |
Common causes
The exclude entry names fewer keys than the combination has
The dominant cause. One key named means one comparison, and every combination agreeing on that key is removed regardless of the others, which is usually a whole slice rather than a single job.
The entry names a value that is not in the cross product
A filter that matches nothing removes nothing, and it is not an error. This happens when a matrix value changes and the exclude entry describing it is not updated in the same change.
The job being excluded was created by an include entry
Configurations that include adds are produced after the exclusion pass has finished, so they are never tested against exclude at all. The only way to stop one is to change the include entry.
The matrix was changed and the count was never rechecked
In our experience this is how a wrong count survives for months. Adding one value to a dimension multiplies the product and can move which combinations an existing partial entry removes.
How to fix it
Name every key needed to identify the combination
To remove exactly one job, the entry has to constrain every dimension. Anything less is a slice, and the number of keys you name is the number of comparisons the builder creates.
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
exclude:
- os: windows-latest
node: 18Print the matrix context and count the legs
- Add a step that serializes the matrix context to the log.
- Set fail fast to false so a single failure does not hide the rest of the expansion.
- Run it and compare the number of legs against the number you expected.
- Leave the step in place for whoever edits the matrix next.
Change the include entry rather than excluding what it adds
A configuration that only exists because include created it cannot be removed by exclude, because it is produced after the exclusion pass. Edit or delete the include entry instead.
Build the matrix from include entries when the set is small
If you want five specific combinations, list five include entries and define no dimensions to cross. Nothing has to be subtracted, so none of the subtleties on this page applies.
strategy:
matrix:
include:
- os: ubuntu-latest
node: 20
- os: windows-latest
node: 22A combination that include added cannot be excluded
This one is invisible from the documentation and clear in the builder, and it catches people who have already understood the partial match. The builder walks the cross product, and for each combination it tests the exclude entries first and skips the combination if one matches, then applies the include entries to whatever survived. After that loop finishes, it takes the include entries that never matched anything and yields each of them as a configuration of its own.
That second stage happens outside the loop, and nothing in it consults the exclude entries. So a configuration that exists only because an include entry created it is never offered to exclude at all. Writing an exclude entry that describes such a job removes nothing, and there is no message, because an exclude entry that matches no combination is not an error.
If you need to stop such a job appearing, delete or change the include entry that creates it. That is the only lever, and once you know the order it is obvious: the job was added after exclusion had already finished.
The ordering is also why the two keys feel asymmetric in a way that goes beyond the partial match. Exclude sees the cross product. Include sees the cross product and then extends the result.
matrix:
node: [18, 20]
include:
- node: 22
exclude:
- node: 22 # removes nothing: the 22 job is added after exclusionSeeing the expansion instead of predicting it
The reliable way to work with any of this is to stop reasoning about it and print it. A single step that serializes the matrix context puts the exact combination for each leg into the log, and the job list then tells you how many legs there are. Two numbers, both observed, replace an argument.
Turn off fail fast while you are doing this. The default cancels the remaining legs when one fails, which is exactly the wrong behavior when you are trying to see the whole expansion, and it makes a matrix look smaller than it is.
Keep the print step in the workflow rather than deleting it once the count is right. It costs nothing, and the next person to change the matrix gets the same evidence without having to think of this.
When the goal is a small number of specific combinations rather than a filtered product, consider not using a cross product at all. A matrix built entirely from include entries lists exactly the jobs you want, and nothing has to be subtracted from it.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
steps:
- run: 'echo ${{ toJSON(matrix) }}'Why there is no recorded run on this page
Nothing on this page fails. Every outcome described here is a successful run with a job count somebody did not expect, so a recorded run would be a screenshot of a job list, and a job list from our matrix says nothing about the shape of yours.
The behavior is instead read out of the matrix builder, where the filter construction, the comparison, and the order of the cross product loop against the trailing include stage are all visible in one file. The partial match is additionally stated in the published schema description for the key, so the two agree and either alone would be enough.
There is also nothing to repair here, and it is worth saying why in this particular case. A matrix that produced the wrong number of legs produced the number your file specifies. There is no failure signal for anything to act on, and a runner that added or removed legs would be overriding the file rather than healing it.
How to prevent it
- Treat the key count in an exclude entry as the width of the filter, and check it.
- Keep a step that prints the matrix context permanently in any workflow with a matrix.
- Recheck the job count whenever a dimension gains or loses a value.
- Prefer an explicit list of include entries when the wanted set is small.
Frequently asked questions
Why did my matrix exclude remove more jobs than I expected?
Why does my exclude entry remove nothing at all?
Can I exclude a job that an include entry added?
How do I see the combinations my matrix actually produced?
Related guides
References
- actions/runner: MatrixBuilder.cs, the filter comparisons and the cross product loop
- actions/runner: the published workflow schema, the matrix exclude description
- GitHub Actions: run variations of a job with a matrix
- GitHub Actions: workflow syntax, jobs.<job_id>.strategy.matrix.exclude
- GitHub Actions documentation