Matrix builds for multiple versions
Master matrix builds for multiple versions in CI/CD. Learn how to test across combinations efficiently, with hands-on steps and next steps.
Focus: matrix builds for multiple versions
You've set up a solid CI/CD pipeline for your Python project — tests pass, artifacts build, and deployments flow. But what happens when the same pipeline must validate your code against Python 3.9, 3.10, 3.11, and 3.12, plus multiple dependency versions? Copy-pasting pipeline blocks is a maintenance nightmare, while running only the latest version leaves compatibility bugs lurking until your users find them. Matrix builds let you define a single pipeline that expands into multiple parallel jobs — each testing a different combination of versions — so you get comprehensive coverage without duplicating configuration. This lesson shows you how to use matrix builds for multiple versions in GitHub Actions (and how the same idea applies to other CI systems).
The problem this lesson solves
If you've ever maintained a CI pipeline by hand, you've probably hit this wall: your project needs to work across Python 3.9, 3.10, 3.11, and 3.12, and maybe also with different dependency versions (like Django 4.2 versus 5.0). You could write four separate jobs, but that means:
- Duplicated YAML — every tweak to the pipeline (like adding a new step or env var) must be copied to all jobs, and you'll likely forget one.
- Hard-to-read workflows — a 300-line file with near-identical blocks is tough to review and even tougher to debug.
- Missed combinations — if you also have dependency version variants, the number of jobs explodes (4 Python versions × 2 dependencies = 8 jobs). Hand-writing that is error-prone.
- Slow feedback loops — running jobs sequentially (one after another) delays finding failures; you want them in parallel.
Without a matrix, you either under-test (only latest versions) or over-maintain (copy-paste city). Matrix builds solve both by letting you define the axes of versions once, then letting CI generate the combinations for you.
Core concept / mental model
Think of a matrix build like a spreadsheet of test configurations. You define columns (Python version, dependency version, OS) and let the CI engine create every combination of rows. Each row becomes an independent job: it gets a unique set of environment variables, runs the same steps, and reports its own pass/fail result.
Key terms:
- Matrix — the full set of combinations you want to test. In GitHub Actions, defined with the
strategy.matrixkey. - Axis — one dimension of variation, like
python-versionoros. Each axis lists the values to try. - Cell — one combination from all axes (e.g., Python 3.11 + Ubuntu). Each cell becomes one job.
- Include / exclude — optional filters to add or remove specific combinations (covered in troubleshooting).
In pseudo-code, a matrix is just nested loops:
for python_version in ['3.9', '3.10', '3.11', '3.12']:
for dependency_set in ['django42', 'django50']:
run_job(python_version, dependency_set)
CI runs all combinations in parallel by default, giving you fast, comprehensive feedback.
How it works step by step
1. Define your axes
Decide what varies: Python versions, dependency versions, OS, architecture — anything that affects test outcomes. Keep axes minimal; every extra entry multiplies the number of jobs.
2. Declare the matrix in your workflow
In GitHub Actions, inside your job, add a strategy.matrix block. Each axis is a key; its value is a list of choices.
3. Reference matrix values dynamically
Use the matrix context in your steps, e.g., ${{ matrix.python-version }}. For Python, you'll set python-version in actions/setup-python; for dependencies, you can pass the value to a script.
4. Run the pipeline
GitHub Actions expands the matrix into that many jobs, each labeled with the combination (e.g., "build (3.11, ubuntu-latest)"). Failures in one job don't affect others unless you opt in to fail-fast.
That's the whole idea. Let's see it in action.
Hands-on walkthrough
Step 1: Set up a Python project
Create a simple Python package with a test file to verify your matrix build works.
# mypackage/__init__.py
def add(a, b):
"""Add two numbers, but pretend it uses a version-specific feature."""
return a + b
# tests/test_add.py
from mypackage import add
def test_add():
assert add(2, 3) == 5
Step 2: Write a GitHub Actions workflow with a matrix
Create .github/workflows/ci.yml:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # Don't cancel other jobs if one fails
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
django-version: ['django42', 'django50']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ "${{ matrix.django-version }}" == "django42" ]; then
pip install Django>=4.2,<5.0
else
pip install Django>=5.0,<6.0
fi
- name: Run tests
run: python -m pytest -v
Step 3: Run and observe
Push this to your repository and watch the Actions tab. You'll see 8 jobs (4 Python versions × 2 Django versions), each running in parallel. Each job shows the matrix values, e.g., test (3.11, django50).
Expected output in each job:
============================= test session starts ==============================
collected 1 item
tests/test_add.py . [100%]
============================== 1 passed in 0.03s ===============================
Pro tip: Set
fail-fast: falseunless you want to stop the whole matrix on the first failure. In CI, you usually want to see all failures at once, not just the earliest one.
Step 4: Access matrix values in your code
Sometimes you need the matrix value inside your test suite, for example, to skip a test for a specific version. You can pass it as an env var:
- name: Run tests with version
env:
PYTHON_VERSION: ${{ matrix.python-version }}
DJANGO_VERSION: ${{ matrix.django-version }}
run: python -m pytest -v
Then in your code:
import os
if os.environ.get("DJANGO_VERSION") == "django42":
# skip a test that's incompatible with Django 4.2
pass
This shows how matrix values propagate through the entire job.
Compare options / when to choose what
Matrix builds aren't the only way to test multiple versions. Here's a comparison:
| Approach | Pros | Cons | Best for |
|---|---|---|---|
| Matrix builds | Parallel, config-driven, minimal duplication | Adds complexity; many jobs can be heavy | Projects with multiple axes (Python × dependency × OS) |
| Single job with loop | Simple, few files | Sequential, slow; harder to see per-version failures | Quick local checks, very small projects |
| Separate workflow files | Clear separation of concerns | Massive duplication; hard to keep in sync | Different environments entirely (e.g., dev vs prod) |
| Reusable workflows | DRY, can be called with different inputs | Overhead to set up; still limited if you need dynamic combinations | Standard shared steps across many jobs |
When to choose matrix builds:
- You need to test multiple versions of Python, dependencies, or OSes.
- You want all combinations visible in the CI UI as separate pass/fail badges.
- Your team values fast feedback parallel execution.
When to avoid:
- The matrix explodes in size (e.g., 20 × 20 = 400 jobs) — then you should reconsider or use more dynamic strategies.
- Steps between versions are completely different — then separate jobs make more sense.
Troubleshooting & edge cases
1. Job count explodes too much
Adding too many axes multiplies jobs rapidly. Use include and exclude to fine-tune:
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
os: ['ubuntu-latest', 'windows-latest']
exclude:
- python-version: '3.9'
os: 'windows-latest' # Don't test Python 3.9 on Windows
include:
- python-version: '3.12'
os: 'ubuntu-latest'
experimental: true # Add a flag for a special job
2. setup-python fails with "Version X not found"
Make sure the version exists on the runner. Use 3.9 instead of 3.9.0 to automatically get the latest patch. Check the runner's available versions if errors persist.
3. Matrix values with dots or special characters
If an axis value contains a dot (like 3.11), you must use it inside JSON-like expressions carefully. In if: conditions, quote it: if: matrix.python-version == '3.11'.
4. One job fails, but you still want to see others
Set fail-fast: false. By default, fail-fast: true cancels all remaining jobs when one fails, which hides other failures.
5. Tests behave differently on Windows vs Linux
Matrix OS axes are great, but remember file path separators and newlines differ. Test on Linux first, then add Windows to the matrix after catching platform-specific bugs locally.
6. Matrix step environment doesn't pass to subprocesses
If you set env vars via env: in a step, child processes inherit them. But if you modify the env mid-step, it won't persist to the next step unless you write to $GITHUB_ENV.
What you learned & what's next
You now understand the core idea behind matrix builds for multiple versions: you define axes of variation, and CI expands them into parallel jobs. You practiced setting up a matrix in GitHub Actions, passing matrix values into steps, and troubleshooting common gotchas like job explosion and fail-fast behavior.
This skill is central to CI/CD because it lets you test compatibility across the many versions your users run. In the next lesson in the CI/CD foundations track, you'll learn how to cache dependencies to speed up your matrix jobs — because running 8 jobs in parallel can quickly drain your runner minutes. Matrix builds and caching together will make your pipelines both comprehensive and fast.
Practice recap
Now try adding a third axis to your workflow, like os: [ubuntu-latest, windows-latest], and then use exclude to drop one combination you don't need. Push the change and watch the Actions tab to see how the job count changes. Don't forget to set fail-fast: false so you can see every failure at once.
Common mistakes
- Forgetting to set fail-fast: false, so one failing job cancels all others and you miss other version-specific issues.
- Using hard-coded Python versions like '3.9.0' instead of '3.9', which can fail when the runner updates to a new patch.
- Adding too many axes to the matrix, leading to an explosion of jobs that slow down the whole pipeline and waste resources.
- Not quoting matrix values in conditional expressions, e.g.,
if: matrix.python-version == 3.11instead of'3.11', causing YAML parse errors.
Variations
- Use
strategy.fail-fastwithmax-parallelto limit the number of concurrent jobs and control cloud costs. - Employ the
includekey to add extra jobs with special configurations (e.g., experimental flags). - For Jenkins, use declarative pipeline's
matrixdirective — the same concept but with different syntax.
Real-world use cases
- Open-source libraries like NumPy run matrix builds across Python 3.9–3.13 and multiple OSes to guarantee compatibility.
- Web frameworks like Django test against several database versions (PostgreSQL, MySQL, SQLite) using matrix axes for DBs.
- Mobile app CI pipelines use matrix builds to compile and test the same app against different Android API levels or iOS versions.
Key takeaways
- Matrix builds let you test multiple versions in parallel from a single pipeline definition.
- Define axes in
strategy.matrix; each axis value combination becomes a separate job. - Reference matrix values with
${{ matrix.axis-name }}anywhere in your workflow. - Use
excludeandincludeto fine-tune the matrix size and add special cases. - Set
fail-fast: falseto surface all failures instead of canceling the matrix on the first error. - Matrix builds pair beautifully with caching to keep runtime fast even with many jobs.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.