Build and Package PyO3 Wheels for Distribution
A Rust extension lives only in source form until you package it as a wheel—a standardized binary distribution format that Python package managers (pip) understand. A wheel contains precompiled native code, metadata, and Python glue, ready to install without compilation. This article teaches you to build wheels for multiple Python versions and platforms, publish to PyPI (the official package repository), and automate the process with continuous integration. By the end, you will ship production-ready extensions that users install with pip install your_package.
Wheels are the modern solution to the build-on-install problem. Before wheels, users compiled C extensions from source; now, maturin automates compilation and produces wheels for every OS–Python combination, which CI/CD publishes to PyPI automatically.
Understanding Wheel Naming and Compatibility
A wheel filename encodes its compatibility: {distribution}-{version}-{python}-{abi}-{platform}.whl. For example:
my_extension-0.1.0-cp312-cp312-win_amd64.whlis for Python 3.12, Windows 64-bit.my_extension-0.1.0-cp310-cp310-manylinux_2_28_x86_64.whlis for Python 3.10, Linux.my_extension-0.1.0-cp39-cp39-macosx_11_0_x86_64.whlis for Python 3.9, macOS 11+.
Maturin generates these filenames automatically based on the build environment. A single source repository can ship wheels for CPython 3.9–3.12, macOS (Intel + Apple Silicon), Linux (multiple glibc versions), and Windows—all without manual intervention if CI is set up.
Building a Single Wheel Locally
The simplest workflow: build a wheel on your development machine and test it.
maturin build --release
Output is in target/wheels/:
ls target/wheels/
# my_extension-0.1.0-cp311-cp311-win_amd64.whl (on Windows)
# my_extension-0.1.0-cp311-cp311-manylinux_2_28_x86_64.whl (on Linux)
# my_extension-0.1.0-cp311-cp311-macosx_13_0_x86_64.whl (on macOS)
Install and test:
pip install target/wheels/my_extension*.whl
python -c "from my_extension import some_function; print(some_function())"
pyproject.toml Configuration for Wheels
Your pyproject.toml declares metadata that goes into the wheel. Here is a complete example:
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "my_extension"
version = "0.1.0"
description = "Fast Rust extension for Python"
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
authors = [{name = "Your Name", email = "[email protected]"}]
keywords = ["rust", "performance", "extension"]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Operating System :: OS Independent",
]
[project.urls]
Repository = "https://github.com/yourname/my_extension"
Documentation = "https://docs.example.com"
[tool.maturin]
module-name = "my_extension._core"
python-source = "python" # Directory containing .py files to include in the wheel
The module-name is the fully qualified name of the extension module; python-source includes pure-Python files alongside the compiled extension.
Publishing to PyPI with GitHub Actions
Automating wheel builds and uploads to PyPI ensures every release ships wheels for all platforms. Here is a GitHub Actions workflow (.github/workflows/release.yml):
name: Build and publish wheels
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install maturin
run: pip install maturin
- name: Build wheel
run: maturin build --release --out dist
- name: Upload to PyPI
if: startsWith(github.ref, 'refs/tags/')
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: |
pip install twine
twine upload dist/*.whl
This workflow:
- Triggers on git tags (e.g.,
git tag v0.1.0 && git push --tags). - Builds wheels on macOS, Windows, and Linux.
- Tests against Python 3.9–3.12 on each platform.
- Publishes wheels to PyPI using your API token.
To set it up:
- Create a PyPI account at pypi.org and verify your email.
- Generate an API token: go to Account Settings > API Tokens > Create Token.
- In your GitHub repository, go to Settings > Secrets > New Repository Secret.
- Add
PYPI_TOKENwith your API token as the value. - Commit the workflow file and push a tag to test.
Manual Publishing to PyPI
If CI is not yet configured, publish manually:
- Build wheels for all platforms locally or ask contributors to build on their machines:
maturin build --release
- Install
twine:
pip install twine
- Upload to PyPI (you will be prompted for credentials):
twine upload target/wheels/*.whl
- Verify on pypi.org; search for your package.
Testing Wheels Before Publishing
Always test wheels before publishing:
# Create a fresh virtual environment
python -m venv test_env
source test_env/bin/activate # macOS/Linux
# or test_env\Scripts\activate (Windows)
# Install the wheel
pip install target/wheels/my_extension*.whl
# Run your test suite
python -m pytest tests/
Publish only after tests pass.
Building for Multiple Python Versions
Use maturin build with the --python-version flag:
# Build for Python 3.10
maturin build --release --python-version 3.10
# Build for Python 3.11
maturin build --release --python-version 3.11
Alternatively, use GitHub Actions (as above) to build all versions in parallel.
Best Practices for Wheel Distribution
| Practice | Reason |
|---|---|
Pin requires-python conservatively | Only support Python versions you test. Example: requires-python = ">=3.9". |
| Use semantic versioning | MAJOR.MINOR.PATCH (e.g., 1.2.3). Bump MAJOR for breaking API changes. |
Include a CHANGELOG.md | Document what changed in each version; users and downstream packages depend on this. |
| Test wheels from PyPI | After publishing, install in a fresh environment and verify functionality. |
| Sign releases with GPG | Optional but recommended for security. GitHub Actions can auto-sign with gpg-sign. |
| Document build requirements | If users need system dependencies (e.g., OpenSSL), document them in README.md. |
Key Takeaways
- Wheels are precompiled binaries; maturin builds them with
maturin build --release. - Wheel filenames encode Python version, ABI, and platform; maturin generates correct names automatically.
pyproject.tomldeclares metadata and module configuration.- GitHub Actions CI automates wheel building and publishing to PyPI on every release.
- Always test wheels locally in a fresh virtual environment before publishing.
requires-pythonlimits supported versions; be conservative and explicit.
Frequently Asked Questions
What is the difference between maturin develop and maturin build?
maturin develop installs an editable wheel in your current environment (for local development). maturin build creates a wheel file in target/wheels/ (for distribution).
Can I build wheels for older Python versions?
Only if you have that Python version installed locally. Alternatively, use GitHub Actions to build wheels across multiple versions in parallel (recommended).
What if my extension depends on a system library (e.g., OpenSSL)?
Document the requirement in README.md and use system-packages = true in [tool.maturin] if needed. For most users, statically linking libraries into the wheel is better; check the maturin documentation for vendored dependencies.
How do I handle ABI changes between Rust versions?
PyO3 version is typically pinned in Cargo.toml. If you upgrade PyO3, rebuild wheels for all supported Python versions. Wheels from different PyO3 versions are incompatible.
What if my wheel fails to install on some machines?
Check the error message. Common causes: wrong Python version (wheel built for 3.11, user has 3.9), wrong platform (built for Linux, user on Windows), or glibc mismatch on Linux. GitHub Actions with manylinux containers ensures broad Linux compatibility.