uv, maturin, and PyO3 packaging a native extension wheel and a Nix cargo-check gate
The wheel is not enough until the native crate fails closed in a hermetic check.
*A pure-Python wheel is easy to trust. A Rust extension is not, until the same uv project both builds the wheel and fails closed when the native code no longer typechecks.*
The problem
You ship a Python library that wraps a hot path in Rust. Locally it works: you import the module, call a function, and get a number back. Then a teammate changes one line in src/lib.rs, merges on a green Python unit suite, and production starts raising ImportError because the extension never rebuilt. Or CI installs from an sdist on a machine without a Rust toolchain and the build backend fails twenty minutes into the job. Or Nix builds a Python environment from uv.lock and silently prefers a wheel that does not match the source you thought you pinned.
The pain is not "learn Rust." The pain is packaging: how the Python project declares a native build backend, how the Rust crate is shaped so Python can import it, and how a hermetic check fails when the native side breaks, even if you still use uv day to day for the wheel.
Teams hit this when they treat the extension as an implementation detail of the library rather than as a second build graph that must be versioned, cached, and checked. Python tooling sees pyproject.toml. Cargo sees Cargo.toml. Nix, if you use it, sees neither unless you teach it. The Ops job is to make those three views agree before a caller imports the module.
The idea, in plain words
Use uv to create and manage a Python project whose build backend is maturin. Maturin compiles a PyO3 crate (Rust code that exposes Python modules) into an extension module and packages it as a wheel. Keep the day-to-day loop on uv sync, uv run, and uv build. Add a Nix check that runs cargo check on the same crate so the native code cannot drift without failing a hermetic gate. Treat uv2nix as the packaging path when you need the uv.lock world inside Nix: it works for PyO3, but you must declare build-system and Cargo inputs that uv.lock does not record.
Concept A: start the project with uv and the maturin backend
Definition. uv is a Python package and project manager. A build backend is the PEP 517 component that turns project sources into a wheel or editable install. maturin is the build backend that compiles Rust (and related bindings) into Python packages.
Why. Without an extension-capable backend, uv init gives you a pure-Python package. Extension modules need a backend that knows how to invoke Cargo. uv documents that explicitly: choose --build-backend maturin for Rust, and that flag implies --package.
Verified start:
$ uv init --build-backend maturin checksum-ext
Initialized project `checksum-ext` at `.../checksum-ext`
The tree is not a normal app layout. Beside pyproject.toml you get Cargo.toml, src/lib.rs, and a Python package under src/checksum_ext/ with __init__.py and a stub _core.pyi. The generated pyproject.toml wires maturin:
[tool.maturin]
module-name = "checksum_ext._core"
python-packages = ["checksum_ext"]
python-source = "src"
[tool.uv]
cache-keys = [
{ file = "pyproject.toml" },
{ file = "src/**/*.rs" },
{ file = "Cargo.toml" },
{ file = "Cargo.lock" },
]
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
Walk-through: build-system tells every PEP 517 installer (including uv) to call maturin. module-name is the import path of the compiled extension. python-source = "src" keeps the pure-Python package next to lib.rs. cache-keys is the packaging detail that prevents the "Rust changed, wheel stayed stale" failure: uv rebuilds when those files change. Official uv docs say that to force a rebuild when keys are wrong or incomplete, use --reinstall.
Common mistake. Treating the maturin project like a pure app and running only pytest against checked-in .so artifacts. The extension is a build product. If CI does not run the build backend, you are testing yesterday's binary. Spot it when git status is clean on Rust files but the wheel timestamp is old, or when two machines import different symbols from the same tag.
Concept B: PyO3 shapes the crate Python can import
Definition. PyO3 is the Rust library that binds Rust functions and types to the CPython (and PyPy) C API. A cdylib is a dynamic library crate type. An extension module is a shared library that Python loads with import.
Why. Python cannot import a Rust bin or a plain rlib. PyO3's docs require crate-type = ["cdylib"] and the extension-module feature so the crate links as a Python module rather than as a program that embeds Python. The #[pymodule] name must match lib.name in Cargo.toml, or the import fails even when Cargo succeeds.
Lab Cargo.toml (trimmed):
[lib]
name = "_core"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.28.2", features = ["extension-module", "abi3-py39"] }
Lab src/lib.rs (core logic):
use pyo3::prelude::*;
#[pymodule]
mod _core {
use pyo3::prelude::*;
#[pyfunction]
fn fnv1a64(data: &[u8]) -> u64 {
const OFFSET: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x100000001b3;
let mut hash = OFFSET;
for b in data {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(PRIME);
}
hash
}
#[pyfunction]
fn fnv1a64_hex(data: &[u8]) -> String {
format!("{:016x}", fnv1a64(data))
}
}
The Python side re-exports the native symbols so callers never touch _core directly:
from checksum_ext._core import fnv1a64, fnv1a64_hex
def main() -> None:
print(fnv1a64_hex(b"hedronite-ops-lab"))
Verified loop on the lab machine (uv 0.12.13, CPython 3.12.13, rustc 1.99.0):
$ uv sync
Resolved 1 package in 14ms
Building checksum-ext @ file://...
Built checksum-ext @ file://...
Installed 1 package in 1ms
+ checksum-ext==0.1.0 (from file://...)
$ uv run checksum-ext
10c8b8f75fe960f0
$ uv run python -c "from checksum_ext import fnv1a64, fnv1a64_hex; d=b'hedronite-ops-lab'; print(fnv1a64(d)); print(fnv1a64_hex(d))"
1209419872554541296
10c8b8f75fe960f0
uv build produced both an sdist and a platform wheel:
Successfully built dist/checksum_ext-0.1.0.tar.gz
Successfully built dist/checksum_ext-0.1.0-cp39-abi3-macosx_11_0_arm64.whl
The cp39-abi3 tag comes from PyO3's stable ABI feature (abi3-py39): one wheel can serve multiple Python versions at or above 3.9 on that platform. That is a packaging choice with tradeoffs (stable ABI constraints), not a free default for every extension.
Common mistake. Renaming lib.name in Cargo.toml without renaming the #[pymodule] block, or the reverse. Cargo still builds. Python fails with an import error that looks like a path problem. Spot it by comparing lib.name, tool.maturin.module-name, and the #[pymodule] identifier in one sitting.
Concept C: the Nix packaging gate (and where uv2nix fits)
Definition. A hermetic check builds from declared inputs without depending on the developer's ambient toolchain state. uv2nix is a Nix tooling path that turns a uv.lock into a Nix package set via pyproject.nix builders.
Why. uv sync on a laptop proves the extension builds here. It does not prove the Rust still typechecks in a clean environment, and it does not record maturin or Cargo the way Nix expects when you build from sdist. uv2nix's own docs note that uv does not lock build systems; when you build native packages from source you must overlay build-system and Cargo inputs. Maintainers state that PyO3 dependencies work when you follow the advanced build-system pattern and pyproject.nix's guidance for building Cargo packages from source.
For this lesson the verified gate is deliberately narrow and fail-closed: a flake check that runs cargo check --lib on the same crate uv builds, with Python available for PyO3's build script:
checks.${system}.checksum-ext-cargo-check = pkgs.rustPlatform.buildRustPackage {
pname = "checksum-ext";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.python312 ];
buildPhase = ''
export PYO3_PYTHON=${pkgs.python312}/bin/python3
cargo check --lib
mkdir -p $out
echo ok > $out/result
'';
installPhase = "true";
dontCargoInstall = true;
};
Verified:
$ nix build .#checks.aarch64-darwin.checksum-ext-cargo-check -L
...
Checking checksum_ext v0.1.0 (...)
Finished `dev` profile ...
Finished cargoCheckHook
...
# exit 0
What this gate catches: a broken format!, a missing import, a PyO3 API misuse, a lockfile that no longer resolves. What it does not catch by itself: a wrong module-name, a Python-only bug in __init__.py, or a manylinux policy for publishing. Those stay on the uv/maturin side (uv run, tests, uv build in CI).
When you outgrow "Cargo check beside uv" and need the full Python environment inside Nix, that is the uv2nix row: generate an overlay from uv.lock, compose build-system overlays, and for Maturin/PyO3 add the Cargo/Rust inputs the lockfile never listed. Do not expect uv run inside a uv2nix-provisioned shell: uv2nix docs warn that uv run creates its own virtualenv and fights the Nix-managed environment.
Common mistake. Calling a project "Nix packaged" because a flake exists that only wraps uv sync impurely. That is a convenience shell, not a packaging gate. Spot it when the flake has no checks. output, or when the build uses network and the developer's ~/.cargo without cargoLock.
The same discipline shows up when you publish. Maturin's docs recommend building wheels and then publishing with uv publish (or uploading the artifacts your CI already produced). On Linux, manylinux policy still applies: a wheel built on a random developer laptop is not automatically installable everywhere. That is outside today's verified lab (the lab wheel is macosx_11_0_arm64), but it is part of the packaging story an Ops owner must schedule: either build in the manylinux container or the maturin GitHub Action, or ship sdists to environments that compile from source with a Rust toolchain present.
Think about ownership boundaries. The Python package name (checksum-ext), the import package (checksum_ext), the extension module (checksum_ext._core), and the Cargo lib.name (_core) are four strings that must stay coherent. uv's template sets them consistently. Hand edits often break one without breaking Cargo. A packaging review checklist that only opens Cargo.toml will miss a module-name drift in pyproject.toml. A review that only opens Python tests will miss a cdylib crate that no longer exports the function the stub .pyi promises.
When uv2nix enters the picture, keep the division of labor clear. uv remains the authoring and lock tool: it resolves Python dependencies and, with maturin, builds the extension for day-to-day work. Nix becomes the verifier and, later, the deployer of a closed package set. The hermetic cargo check flake is the minimum useful verifier for a PyO3 crate. A full uv2nix package set is what you add when installers must receive the extension from the Nix store rather than from a wheelhouse. Skipping straight to "Nix runs uv sync on a builder" recreates the ambient-toolchain problem with more YAML.
Finally, connect the gate to incident response. If production imports fail after a release, ask which artifact moved: an abi3 wheel from CI, an editable install on a host, or a Nix store path. The remediation differs. A bad wheel is rebuilt and republished. A stale editable is uv sync --reinstall. A bad store path is a flake inputs bump and a failing check you should have seen before promote. The lesson's referent (FNV-1a) is small so those questions stay about packaging, not about hash algorithms.
Putting it together
Frame again: a small checksum helper that must be fast and must not silently ship a stale binary.
- Scaffold with
uv init --build-backend maturin. - Replace the sample
hello_from_binwith real#[pyfunction]exports; keep__init__.pyas the stable Python API. - Develop with
uv sync/uv run. Rely ontool.uv.cache-keysso Rust edits invalidate the install. - Produce artifacts with
uv build(sdist + wheel) when you need something to upload or to install on another host. - Fail closed in CI with a Nix check on
cargo check --libfor the sameCargo.lock. When the team requires the Python package set itself to be Nix-built, graduate that check to uv2nix with explicit Maturin/Cargo overlays rather than pretendinguv.lockalone is enough.
The packaging gate is the point of the lesson. The FNV-1a function is only the referent that makes the gate observable: if the Rust is wrong, both uv sync and the Nix check fail before any caller sees a wrong digest.
Check yourself
- What does
uv init --build-backend maturinadd that a defaultuv initdoes not, and why does uv say that flag implies--package? - Why must a PyO3 extension use
crate-type = ["cdylib"]and theextension-modulefeature? - You change
src/lib.rsbutuv runstill prints the old hex string. Whichpyproject.tomltable should you inspect first, and what flag forces a rebuild? - What does the hermetic
cargo checkflake catch that a green pure-Python test suite can miss? What does it not catch? - Why is "we have a
uv.lock, so uv2nix needs no overrides" a misreading for a Maturin project?
Sources
- Astral uv documentation, Creating projects (extension modules, maturin backend, cache-keys): https://docs.astral.sh/uv/concepts/projects/init/
- PyO3 user guide, Getting started (maturin, cdylib, extension-module, pymodule naming): https://pyo3.rs/main/getting-started
- Maturin user guide, Introduction and Local development: https://www.maturin.rs/ · https://www.maturin.rs/local_development.html
- uv2nix, Getting started and Advanced build systems; pyproject.nix Cargo-from-source guidance: https://pyproject-nix.github.io/uv2nix/usage/getting-started.html · https://pyproject-nix.github.io/uv2nix/patterns/advanced-build-systems.html
- The DevOps Handbook (Kim, Humble, Debois, Willis), environment-based release patterns / canary release discussion (fail-closed promotion discipline as analogy for packaging gates), print index ~pp.169–171