Build and Test Python Wheels in GitHub Actions
A Python wheel (.whl) is a pre-built binary distribution format that installs packages instantly without requiring compilation. Building wheels in CI ensures your package installs quickly for users and catches build-time errors before release.
Modern Python packaging uses pypa/build, a standard tool that respects pyproject.toml configuration. In this guide, you'll build wheels for multiple Python versions and operating systems, test them in isolation, and store them as artifacts for later publication to PyPI.
Understanding Python Wheels
A wheel is a ZIP archive containing pre-compiled Python bytecode, native C extensions (if any), and metadata. Installing from a wheel skips the build step (compilation, dependency resolution), so installation is 10–50x faster than building from source.
Wheels are platform-specific if they contain compiled extensions (C code). A pure Python wheel runs on any Python version and OS. Wheels naming convention: {package}-{version}(-{build})?-{python}-{abi}-{platform}.whl, e.g., requests-2.31.0-py3-none-any.whl (pure Python) or numpy-1.24.0-cp311-cp311-win_amd64.whl (compiled, Python 3.11, Windows 64-bit).
Building Wheels with pypa/build
Install build:
pip install build
Then build wheels from your project:
python -m build --wheel
This respects your pyproject.toml and outputs wheels to dist/. For a pure Python package, you get one wheel for all platforms. For packages with C extensions (NumPy, psycopg2), you need to build on each OS.
Configuring pyproject.toml
Modern Python projects use pyproject.toml to declare build configuration. Here's a minimal setup:
[build-system]
requires = ["setuptools>=65.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mypackage"
version = "1.0.0"
description = "My Python package"
dependencies = ["requests>=2.25.0"]
[project.optional-dependencies]
dev = ["pytest>=7.0", "black>=23.0"]
The [build-system] section tells build tools (like GitHub Actions) how to build your package. When you run python -m build, it installs the requires dependencies and calls the build-backend.
Building Wheels in GitHub Actions
Create a workflow that builds wheels for multiple Python versions and OSs:
name: Build Wheels
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.9', '3.11', '3.13']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build wheel
- name: Build wheels
run: python -m build --wheel
- name: Upload wheels as artifacts
uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}-py${{ matrix.python-version }}
path: dist/*.whl
This workflow:
- Triggers on version tags (e.g.,
v1.0.0) - Builds wheels on Ubuntu, macOS, and Windows
- For each OS, tests Python 3.9, 3.11, and 3.13
- Uploads wheels to GitHub as artifacts
The matrix spawns 9 jobs (3 OSs × 3 Python versions). Each builds wheels specific to its configuration.
Testing Wheels Before Publishing
Always test wheels in a fresh environment to catch missing dependencies:
- name: Build wheels
run: python -m build --wheel
- name: Test wheel installation
run: |
pip install dist/mypackage*.whl
python -c "import mypackage; print(mypackage.__version__)"
This installs the built wheel in a fresh environment and imports the package. Missing dependencies or compilation errors surface immediately.
For packages with native extensions, test wheels on each target OS:
- name: Install and test wheel
run: |
pip install pytest
pip install dist/mypackage*.whl
pytest tests/
Storing Wheels as Artifacts
GitHub Actions artifacts preserve build outputs for later use. Artifacts are free but limited to 90 days of retention. Download them from the Actions tab or via the API:
curl -L https://api.github.com/repos/owner/repo/actions/runs/{run_id}/artifacts \
| jq -r '.artifacts[] | .archive_download_url' | xargs -I {} curl -O -L {}
Alternatively, programmatically download wheels from a previous job in the same workflow:
- name: Download all wheels
uses: actions/download-artifact@v4
with:
path: dist/
This downloads all artifacts from previous jobs into dist/, preparing them for publication.
Building Source Distributions (sdist)
Alongside wheels, provide source distributions for users who need to build locally or customize the build:
- name: Build distributions
run: python -m build # Builds both wheel and sdist (source distribution)
By default, python -m build builds both *.whl and *.tar.gz files. Upload both to PyPI for maximum compatibility.
Handling Platform-Specific Code
If your package has OS-specific code (Windows-only paths, Unix-only syscalls), test on each OS:
import sys
import os
if sys.platform == "win32":
def get_config_dir():
return os.path.join(os.getenv("APPDATA"), "myapp")
else:
def get_config_dir():
return os.path.expanduser("~/.config/myapp")
Your matrix workflow tests this function on Windows, macOS, and Linux, catching OS-specific bugs before release.
Key Takeaways
- Wheels are pre-built distributions that install 10–50x faster than source distributions.
- Use
python -m build --wheelto build wheels respectingpyproject.toml. - Use a matrix workflow to build wheels on multiple OS and Python versions.
- Always test wheel installation in a fresh environment to catch missing dependencies.
- Upload wheels as GitHub artifacts for download or later publication to PyPI.
- Provide both wheels (
.whl) and source distributions (.tar.gz) on PyPI.
Frequently Asked Questions
What is the difference between a wheel and a source distribution?
A wheel is pre-built and installs instantly. A source distribution (tarball) contains source code and metadata; users must build it, requiring a compiler and build tools. Wheels are preferred for most users.
How do I build platform-specific wheels?
Use a matrix workflow to run builds on each target OS. Each OS builds a wheel specific to its platform. For pure Python packages, you only need one build (any OS).
Can I build wheels for multiple Python versions on a single OS?
Yes, use a matrix. This is useful for pure Python packages that support multiple versions. For packages with native extensions (NumPy, Pandas), you must build on the target OS and Python version combination.
What if my package requires system libraries (libpq, openssl)?
Install them using the OS package manager (apt on Ubuntu, brew on macOS, choco on Windows). For complex dependencies, consider providing pre-built wheels or Docker images instead.
How do I automate wheel publishing to PyPI?
After building wheels, use twine to upload to PyPI. Add a publish job that downloads artifacts and runs twine upload. This is covered in the next article (Publish to PyPI).