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

Sponsored

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.matrix key.
  • Axis — one dimension of variation, like python-version or os. 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: false unless 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.11 instead of '3.11', causing YAML parse errors.

Variations

  1. Use strategy.fail-fast with max-parallel to limit the number of concurrent jobs and control cloud costs.
  2. Employ the include key to add extra jobs with special configurations (e.g., experimental flags).
  3. For Jenkins, use declarative pipeline's matrix directive — 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 exclude and include to fine-tune the matrix size and add special cases.
  • Set fail-fast: false to 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.