How to Package Python Projects with Native Extensions
Native extension packages need two release artifacts: one source distribution and one or more platform wheels. The sdist lets unsupported platforms build from source. The wheels spare most users from installing a compiler.
Use this guide after the extension already builds locally. To create the extension first, start with Build a Python library with a C extension or Build a Python library with a Rust extension.
Prerequisites
- A project with a working C, C++, Rust, Cython, or similar extension module
- A
[build-system]table in pyproject.toml - A compiler toolchain for the local platform
- uv installed
Select the backend that owns compilation
Choose a build backend that understands the native build system instead of hiding compiler commands in ad hoc scripts:
| Extension code | Common backend | Use when |
|---|---|---|
| C or C++ with Python’s extension API | setuptools | The project has a small Extension(...) configuration and no separate CMake or Meson build |
| C, C++, Fortran, Cython, or pybind11 with CMake | scikit-build-core | The native project already uses CMake, or the package needs CMake’s compiler and dependency discovery |
| C, C++, Fortran, Rust, or mixed native code with Meson | meson-python | The native project already uses Meson |
| Rust with PyO3, cffi, UniFFI, or Rust binaries | maturin | Cargo owns the native build |
For a CMake-backed extension, the pyproject.toml starts with scikit-build-core:
[build-system]
requires = ["scikit-build-core"]
build-backend = "scikit_build_core.build"For a Rust extension, the pyproject.toml starts with maturin:
[build-system]
requires = ["maturin>=1,<2"]
build-backend = "maturin"Keep generated C files, headers, Cargo.toml, CMakeLists.txt, Meson files, and source files in the sdist. A user who misses the wheel matrix should still have the files needed to compile.
Set the Python and ABI target
Declare the Python versions the package supports:
[project]
requires-python = ">=3.10"The supported Python range controls the wheel matrix in CI. A native extension that uses the version-specific CPython ABI usually needs one wheel per Python minor version, operating system, and architecture. A project that uses Python’s limited C API can publish abi3 wheels, which remove the Python-minor-version dimension for each platform.
Use abi3 only when the extension and backend document support for it. It is a compatibility promise, not a filename tweak.
Build the local artifacts
Run the build from the project root:
$ uv run --with build python -m build --sdist --wheel
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
- scikit-build-core
* Building sdist...
* Building wheel...
Successfully built example_native-0.1.0.tar.gz and example_native-0.1.0-cp312-cp312-linux_x86_64.whl
uv run executes the command inside the project’s virtual environment, ensuring all dependencies are installed first. The build frontend reads [build-system], installs the backend in an isolated build environment, and writes artifacts to dist/.
Your exact filename depends on the Python version and platform used for the build. The wheel filename should not end in py3-none-any. A native extension wheel should carry a Python tag, ABI tag, and platform tag, such as cp312-cp312-macosx_14_0_arm64 or cp312-abi3-manylinux_2_28_x86_64.
A plain local Linux build may produce a linux_x86_64 wheel. Use cibuildwheel for release builds that need manylinux or musllinux compatibility tags.
Check the artifacts before uploading
Run metadata checks on both artifacts:
$ uv run --with twine twine check dist/*
Checking dist/example_native-0.1.0-cp312-cp312-linux_x86_64.whl: PASSED
Checking dist/example_native-0.1.0.tar.gz: PASSED
Then inspect the wheel contents:
$ uv run --with check-wheel-contents check-wheel-contents dist/*.whl
dist/example_native-0.1.0-cp312-cp312-linux_x86_64.whl: OK
Before publishing, confirm the wheel includes the compiled extension file (.so, .pyd, or .dylib as appropriate), the sdist includes every native source file needed for a rebuild, and the wheel tag matches the platform where it was built.
If the wheel is tagged py3-none-any, the backend treated the package as pure Python. Fix that before publishing, because installers may otherwise choose the wheel on platforms where the extension cannot load.
Hand release wheels to CI
Build release wheels with cibuildwheel instead of building them by hand on a laptop. Start with one Python version per platform, prove the package imports from the built wheel, then use How to Build Multi-Platform Wheels with cibuildwheel to add the full GitHub Actions workflow.
Keep GPU builds separate from wheel tags
Wheel tags do not describe CUDA, ROCm, driver versions, or CPU instruction levels. A wheel such as manylinux_2_28_x86_64 says nothing about whether it expects AVX2, CUDA 12, or CUDA 13.
If the extension links GPU libraries or ships GPU kernels, document that compatibility in the package docs and test matrix. For install-time CUDA selection, see How to Install CUDA PyTorch Wheels Without a GPU. For the wheel-variants proposal, see What Are Wheel Variants?.