Skip to content

hatch-maturin-build

test docs PyPI

A hatchling build hook that compiles Rust extension modules with maturin, so a project can use hatch for environments, versioning, and packaging while maturin handles only the Rust.


Why

maturin is an excellent PEP 517 backend, but it is the backend — you cannot compose it with the rest of hatchling's build pipeline. Anything that wants a Rust extension and generated stubs, a rendered README, a VCS-derived version, or any other build hook currently has to choose.

This plugin inverts the relationship. hatchling stays the backend and owns metadata, file selection, editable installs, and the wheel itself; maturin is invoked for the one thing it is uniquely good at.

Installation

[build-system]
requires = ["hatchling", "hatch-maturin-build"]
build-backend = "hatchling.build"

cargo must be on PATH. maturin is pulled in as a dependency of this plugin.

Usage

[build-system]
requires = ["hatchling", "hatch-maturin-build"]
build-backend = "hatchling.build"

[project]
name = "mypkg"
dynamic = ["version"]

# Read by maturin, exactly as it always was. This plugin does not reparse it.
[tool.maturin]
python-source = "python"
module-name = "mypkg.mypkg"

[tool.hatch.version]
path = "python/mypkg/__init__.py"

[tool.hatch.build.targets.wheel]
packages = ["python/mypkg"]

[tool.hatch.build.targets.wheel.hooks.maturin]
features = ["pyo3/abi3-py39"]

Then build with anything: hatch build, python -m build, pip install ., uv build.

Configuration lives in three layers, in increasing precedence:

  1. [tool.maturin] — what the crate is. Owned by maturin, untouched by this plugin, and still valid if you switch back to build-backend = "maturin".
  2. [tool.hatch.build.targets.<target>.hooks.maturin] — how this build target compiles it.
  3. Environment variables — the per-invocation escape hatch (see below).

Options

Option Type Default maturin flag
manifest-path str Cargo.toml --manifest-path
profile str release --profile
target str host --target
target-dir str cargo default --target-dir
bindings str auto --bindings
auditwheel str maturin default --auditwheel
jobs int cargo default --jobs
features list[str] [] --features (repeated)
compatibility list[str] auto --compatibility (repeated)
config list[str] [] --config (repeated)
all-features bool false --all-features
no-default-features bool false --no-default-features
generate-stubs bool false --generate-stubs
include-debuginfo bool false --include-debuginfo
strip bool false --strip
zig bool false --zig
locked / frozen / offline bool false same
args list[str] [] appended verbatim
executable str auto path to the maturin binary
interpreter str sys.executable --interpreter
sdist-include list[str] [] extra files for the sdist

Unknown options are an error rather than a silent no-op. Reach for args when you need a flag this table does not cover.

Note that profile defaults to release even though bare maturin build defaults to debug. A PEP 517 build should be optimized, which is what maturin's own backend does.

Environment variables

hatchling discards PEP 517 config_settings entirely — every hook in hatchling/build.py marks the parameter unused — so pip install --config-settings=... cannot reach a build hook. Environment variables are the only channel that works through pip, build, and uv alike:

Variable Effect
HATCH_MATURIN_PROFILE overrides profile, e.g. dev for a debug build
HATCH_MATURIN_TARGET overrides target
HATCH_MATURIN_BINDINGS overrides bindings
HATCH_MATURIN_FEATURES overrides features (comma- or space-separated)
HATCH_MATURIN_ARGS extra flags, shell-split
HATCH_MATURIN_EXECUTABLE path to the maturin binary
HATCH_MATURIN_PROFILE=dev pip install --no-build-isolation -e .

Inside hatch, put them in [tool.hatch.envs.<env>.env-vars] instead.

How it works

maturin has no "just build the extension" output mode: every file-producing command emits a wheel, emits an sdist, or installs into a virtualenv. So the hook runs maturin build into a scratch directory, unpacks the resulting wheel, discards the .dist-info, and hands the remainder to hatchling through build_data.

That round trip is deliberate rather than lazy. On Linux maturin defaults to AuditWheelMode::Repair, which derives the real manylinux/musllinux platform tag from what the extension actually links against, bundles external shared libraries beside it, and rewrites RPATH. Shelling out to a bare cargo build and copying the .so would skip all of that and produce a wheel that is tagged wrong and missing its vendored libraries.

Three consequences worth knowing:

  • The tag comes from maturin. The hook sets build_data["tag"] explicitly rather than using hatchling's infer_tag, which only knows the host interpreter and is therefore wrong for both abi3 and cross builds.
  • hatchling owns the Python files. Any member of maturin's wheel that hatchling already ships is dropped, so force_include never shadows a file another build hook may have rewritten. Files maturin generates (cffi glue, bin shims, stubs) have no source counterpart and are kept.
  • *.data/ is re-homed. Scripts and data go through build_data["shared_scripts"] and ["shared_data"] so hatchling names the .data directory. maturin falls back to the Cargo.toml version whenever hatch owns the version dynamically, so the directory name it picked cannot be trusted.

Editable installs

hatchling's editable wheel points an import hook at your source directories and forces a py3-none-any tag. An extension force-included into such a wheel lands in site-packages, where nothing will ever look for it. So for editable builds the hook copies the compiled artifacts into the source tree, the way maturin develop does, and records what it wrote in <cargo target dir>/.hatch-maturin-editable.json so hatch clean can remove them again.

The hook refuses to overwrite any file in the source tree that it did not write itself.

pip install --no-build-isolation -e .

--no-build-isolation is what recovers most of the speed difference against maturin develop; the rest is the wheel zip round trip, which is small next to a cargo build.

sdists

hatchling's sdist is VCS-based, so a git-tracked crate is already covered. The hook force-includes Cargo.toml and Cargo.lock as a safety net, plus anything in sdist-include.

Known gap: path dependencies outside the project root are not enumerated. There is no way to do that without cargo package --list, which demands a far stricter workspace state than a build hook should impose.

Limitations

These are limits of building through maturin's wheel output rather than of the hook itself, and are tracked upstream in PyO3/maturin#1419:

  • One cargo build per wheel. hatchling invokes the hook once per wheel, so a non-abi3 build for N interpreters is N compiles. Prefer abi3.
  • Stubs cost a second compile. maturin generate-stubs runs its own cargo build.
  • Output discovery is a convention, not a contract. "Everything that is not .dist-info is mine" is not a promise maturin makes, so a maturin release could change what the hook sees.
  • Metadata is computed twice. maturin needs a [project] table valid enough not to error, even though its metadata output is discarded.

All four dissolve if maturin grows a build-extension subcommand that emits artifacts plus a JSON manifest. The manifest this hook assembles internally is deliberately shaped like that future output, so adopting it would be a change to a single method.

Development

Run the unit tests on the current interpreter:

hatch test

Run them across every supported interpreter:

hatch test --all

Run the integration test, which compiles a real pyo3 crate and therefore needs cargo and a network. It is deselected by default:

hatch test -- -m integration

Lint, format, and type check:

hatch check

Serve the documentation locally:

hatch run docs:serve

Releasing

Publishing runs on PyPI Trusted Publishing — there are no API tokens in the repository. Bump the version in src/hatch_maturin_build/__about__.py, then push a matching tag:

git tag v0.1.0 && git push origin v0.1.0

The workflow refuses to publish if the tag and the package version disagree.

License

hatch-maturin-build is distributed under the terms of the MIT license.