The matrix strategy in GitHub Actions serves as one of the most potent mechanisms for executing parallel builds across diverse configurations. It enables developers to define a set of variables, such as programming language versions, operating systems, or input parameters, which the system then uses to generate multiple job instances from a single job definition. While this capability significantly accelerates continuous integration pipelines by distributing workloads, uncontrolled expansion of these jobs can lead to resource exhaustion, queue bottlenecks, and inefficiencies. Effective management of this concurrency requires a deep understanding of the max-parallel directive, the inherent limits of GitHub-hosted runners, and the behavioral nuances of job scheduling.
Defining the Matrix Strategy
At its core, the matrix strategy is defined within the strategy.matrix key, located below the job ID in a workflow file. Conceptually, this structure functions as a dictionary where keys represent variable names and values represent the specific configurations against which the job should execute. This abstraction allows a single job definition to fan out into numerous concurrent runs, each tailored to a specific permutation of the defined variables.
yaml
jobs:
example-job:
strategy:
matrix:
version: [1, 2, 3]
In the configuration above, the version key is associated with three distinct values. The system interprets this by generating three separate job instances, each running with a different value for the version variable. This foundational concept extends beyond simple arrays to include multi-dimensional matrices, where multiple keys intersect to create a grid of configurations. For instance, a matrix might define both a configuration (e.g., debug, release) and an arch (e.g., x86, arm), resulting in four distinct job combinations.
Controlling Concurrency with Max-Parallel
While the default behavior of a matrix is to spawn all possible combinations simultaneously, this can overwhelm runners or exceed account limits. The max-parallel key provides the primary mechanism for throttling this expansion. By specifying a maximum number of jobs allowed to run in parallel, developers can manage resource utilization and prevent the system from initiating more concurrent processes than the infrastructure can support.
yaml
jobs:
test-matrix:
strategy:
matrix:
fail-fast: true
max-parallel: 5
configuration: [debug, release]
arch: [x86, arm]
exclude:
- configuration: debug
arch: arm
runs-on: ubuntu-latest
steps:
# steps definition
In this example, the matrix defines four potential combinations of configuration and arch, but one is excluded, leaving three potential jobs. However, the max-parallel value is set to 5. If the matrix were larger, max-parallel would cap the simultaneous executions. This setting is particularly valuable when dealing with expensive resources, such as GPU instances or macOS runners, or when adhering to strict concurrency limits imposed by the GitHub plan.
The behavior of max-parallel has been a subject of discussion regarding its scheduling mechanics. Some observations suggest that max-parallel exhibits blocking behavior. For example, if a matrix generates 20 total jobs and max-parallel is set to 5, the system may start the first 5 jobs and wait until all 5 complete before initiating the next batch of 5. This contrasts with an ideal fluid scheduling model where new jobs would start immediately as old ones finish, maintaining a constant parallel count of 5. Testing has yielded mixed results; some users have not reproduced this blocking behavior on GitHub-hosted or self-hosted runners, suggesting that the scheduling algorithm may vary or that the perceived blocking is an artifact of runner availability rather than a strict enforcement rule. Regardless, the documentation notes that when using GitHub-hosted runners, usage limits may influence how these jobs are queued and scheduled.
Refining Matrix Configurations
Beyond simple key-value pairs, the matrix strategy supports advanced configuration techniques to optimize builds and exclude unnecessary permutations.
Excluding Configurations
The exclude key allows for the removal of specific combinations from the matrix. This is essential when certain configurations are incompatible or redundant. In the previous example, the combination of configuration: debug and arch: arm was excluded. The system calculates the total number of jobs by generating the full Cartesian product of the keys and then subtracting any matches defined in the exclude list.
Including Additional Data
The include key expands the matrix by adding extra data to specific configurations. This is useful when a particular combination requires unique parameters that do not apply to the rest of the matrix. For instance, when building Docker images, different Dockerfile locations might require different build contexts.
yaml
strategy:
matrix:
dockerfile: [Dockerfile, Dockerfile.prod]
include:
- dockerfile: Dockerfile
context: ./default-folder
In this scenario, the include key specifies that when the dockerfile is Dockerfile, the build context should be ./default-folder. This allows for fine-grained control over job parameters without creating complex conditional logic within the job steps. It enables parallel builds of multiple Docker images, each with its specific context, streamlining the CI/CD pipeline.
System Limits and Rate Throttling
Understanding max-parallel is insufficient without recognizing the hard limits imposed by GitHub Actions. These limits vary based on the subscription plan, runner type, and specific usage patterns. Exceeding these limits results in workflow cancellations or queuing delays.
Job Concurrency Limits
The number of concurrent jobs allowed is dictated by the GitHub plan and the type of runner used. These limits are strict and cannot be universally bypassed, though GitHub Support can intervene in specific cases for job concurrency.
| Runner Type | GitHub Plan | Total Concurrent Jobs | Maximum Concurrent macOS Jobs | Maximum Concurrent GPU Jobs |
|---|---|---|---|---|
| Standard GitHub-hosted runner | Free | 20 | 5 | Not applicable |
| Standard GitHub-hosted runner | Pro | 40 | 5 | Not applicable |
| Standard GitHub-hosted runner | Team | 60 | 5 | Not applicable |
| Standard GitHub-hosted runner | Enterprise | 500 | 50 | Not applicable |
| Larger runner | Team | 1000 | 5 | 100 |
| Larger runner | Enterprise | 1000 | 50 | 100 |
A critical note regarding macOS runners is that the maximum concurrent macOS jobs limit is shared across both standard and larger GitHub-hosted runners. For instance, an Enterprise plan allows 50 concurrent macOS jobs total, regardless of whether they are running on standard or larger runners. GPU jobs are only available on larger runners, with limits of 100 for both Team and Enterprise plans. If a workflow generates more jobs than the concurrent limit allows, the excess jobs will be queued until runners become available.
Workflow Execution and Matrix Job Limits
Beyond runner concurrency, there are specific limits on the lifecycle of workflows and the size of matrix expansions.
| Limit Category | Limit | Threshold | Description | Can GitHub Support Increase? |
|---|---|---|---|---|
| Workflow execution limit | Workflow run time | 35 days / workflow run | If a workflow run reaches this limit, it is cancelled. Includes execution, waiting, and approval time. | No |
| Workflow execution limit | Gate approval time | 30 days | A workflow may wait for up to 30 days on environment approvals. | No |
| Workflow execution limit | Job Matrix | 256 jobs / workflow run | A job matrix can generate a maximum of 256 jobs per workflow run. Applies to hosted and self-hosted runners. | No |
| Workflow execution limit | Re-run | 50 re-runs | A workflow run can be re-run a maximum of 50 times. Includes full and subset re-runs. | Yes (Support ticket) |
The Job Matrix limit of 256 jobs per workflow run is a hard ceiling. If a matrix configuration, after applying excludes and includes, results in more than 256 jobs, the workflow will fail. This limit applies regardless of runner type. For larger re-run needs, GitHub Support can increase the re-run limit via a support ticket.
Storage and API Rate Limits
Concurrent jobs also impact storage and API usage. Cache and artifact storage limits are tied to the plan and cannot be increased by GitHub Support.
| Plan | Artifact Storage | Minutes (per month) | Cache Storage |
|---|---|---|---|
| GitHub Free | 500 MB | 2,000 | 10 GB |
| GitHub Pro | 1 GB | 3,000 | 10 GB |
| GitHub Free for organizations | 500 MB | 2,000 | 10 GB |
| GitHub Team | 2 GB | 3,000 | 10 GB |
| GitHub Enterprise Cloud | 50 GB | 50,000 | 10 GB |
Additionally, high concurrency can trigger API rate limits. Unauthenticated requests are limited to 60 per hour per IP address. Authenticated users, using personal access tokens, are limited to 5,000 requests per hour. GitHub Apps or OAuth apps owned by or approved by a GitHub Enterprise Cloud organization enjoy a higher limit of 15,000 requests per hour. When rate limits are hit, workflows may experience delays or failures depending on the specific API call.
Network Considerations for Larger Runners
For teams utilizing larger runners with vnet injection, network configuration becomes a critical component of concurrency management. The subnet IP address range must be sufficiently large to accommodate the maximum anticipated job concurrency. It is recommended to add a buffer to the expected concurrency count. For example, if the maximum job concurrency is set to 300, the subnet should accommodate at least 390 addresses. This accounts for Azure’s reservation of 5 IP addresses in every subnet (the first 4 and the last 1). Very small subnets, such as /29 or smaller, may not provide enough usable addresses to support the required concurrency, leading to deployment failures or network errors.
Conclusion
The GitHub Actions matrix strategy offers a powerful means of parallelizing builds and tests, but its effectiveness is contingent on careful configuration and an understanding of platform limits. The max-parallel key provides essential control over concurrency, preventing resource exhaustion and managing queue depth, though its scheduling behavior may vary. Developers must also account for hard limits, such as the 256-job matrix cap and plan-specific concurrent job limits, particularly for macOS and GPU runners. By leveraging include and exclude keys to refine configurations and understanding the interplay between concurrency, storage, and API rates, teams can build robust, efficient CI/CD pipelines that scale reliably without hitting platform bottlenecks. Integrating specialized tools, such as alternative container build actions, can further optimize performance within these constraints, ensuring that parallel execution delivers the intended speed and reliability.