Hedronite · Ops Lesson · uv2nix + PyO3 · Fri 2026-10-30

uv2nix for a PyO3 extension from uv.lock to a Nix flake that vendors Cargo.lock and runs pytest as a check

One lock per language, both read by the flake, and a CI step for the drift Nix cannot see.

Lesson Class: Ops (Python · uv · maturin · PyO3 · uv2nix · pyproject.nix · Nix flakes)
Tested with: uv 0.13.0 host / 0.13.5 in build · maturin 1.15.0 · cargo 1.99.0 · CPython 3.12.15 · uv2nix c361746 · pyproject-nix bf7d4b1 · nixpkgs 31ecbac · Nix 2.35.2 on aarch64-darwin
Lab: lab/editdist/ (pyproject.toml, Cargo.lock, uv.lock, flake.nix) · four flake stages · real transcripts in lab/out/
Source: uv2nix manual · pyproject.nix manual · Nixpkgs Rust section · uv docs · maturin guide · Nix reference manual
The Lock Is the List
The flake reads uv.lock, so Python dependencies are written down once.
Cargo Wants the Network
Vendor Cargo.lock with importCargoLock and the build stops fetching crates.
Nix Builds What the Lock Says
A dependency missing from uv.lock builds fine and fails on import; uv lock --check catches it.
The build passed. The import failed. The lock was older than the change.

The problem

A small Python library named editdist computes edit distance, the fewest single-character inserts, deletes or substitutions that turn one string into another. The inner loop is written in Rust for speed. Developers manage the project with uv, a Python project and package manager, and its lock file uv.lock pins every Python dependency. A second lock file, Cargo.lock, pins every Rust crate (a crate is a Rust library) that Cargo, Rust's build tool, fetches.

The team also wants a Nix flake, so that CI (continuous integration, the automated build and test run on each change) and colleagues build the identical environment, without a second, hand-written list of Python dependencies that drifts away from uv.lock.

Two snags make this harder than a pure-Python package. A Nix build gets no Rust toolchain unless it asks for one, and Cargo expects to download crates during the build.

The lab ran on 2026-10-30 on an arm64 Mac with uv 0.13.0, Determinate Nix 3.22.3 (Nix 2.35.2) and nixpkgs 31ecbac (2026-10-27) from nixos-unstable, whose cargo and rustc are 1.99.0. The Nix build sandbox, the isolated environment builds normally run in, was off on that machine, which matters in the Cargo section.

The idea, in plain words

uv2nix reads uv.lock with pure Nix code and turns each locked package into a derivation (a Nix build recipe), so the lock file stays the only list of Python dependencies. Build tools that uv does not lock come from a separate overlay (a function that adds or replaces packages in a package set) of common build systems. The Rust half gets its crates from Cargo.lock through a vendoring helper. Built packages become useful once they are collected into a virtual environment (a directory holding an interpreter and installed packages), and at that step you choose which dependency groups go in. The tests then run inside nix flake check. One kind of drift stays invisible to Nix, a dependency added to pyproject.toml without regenerating uv.lock, and uv lock --check catches it.

The project that uv and maturin give you

PyO3 is a Rust library for writing Python extension modules, in other words Rust bindings for Python. maturin is a build backend, the program named in the [build-system] table that turns a source tree into a wheel (Python's pre-built, installable package file). For this project it compiles the Rust crate and packs the result into the wheel. uv.lock records the exact version of every Python package uv resolved, and a Nix flake can read it, which is why the Python side needs no second list.

uv init --lib --build-backend maturin --python 3.12 editdist
uv add --dev pytest
uv lock
uv run pytest -q

The uv documentation lists maturin as the build system for projects with Rust, and says --build-backend implies --package, so the project is built and installed as a package with a Cargo.toml and a lib.rs next to the usual Python files. uv add --dev pytest adds pytest to the development dependency group, not the runtime dependencies.

[project]
name = "editdist"
version = "0.1.0"
description = "Levenshtein edit distance, implemented in Rust"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

[tool.maturin]
module-name = "editdist._core"
python-packages = ["editdist"]
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"

[dependency-groups]
dev = [
    "pytest>=9.2.0",
]

The [tool.maturin] table carries the settings that matter. The maturin guide says module-name is the package name, a dot and the Rust pymodule name, so editdist._core must match the Rust module _core and the lib.name field in Cargo.toml. With python-source = "src", the Python code lives under src/, a layout the guide recommends to avoid a common ImportError pitfall. The cache-keys line is uv's list of files whose change should trigger a rebuild.

[package]
name = "editdist"
version = "0.1.0"
edition = "2024"

[lib]
name = "_core"
# "cdylib" is necessary to produce a shared library for Python to import from.
crate-type = ["cdylib"]

[dependencies]
# "extension-module" tells pyo3 we want to build an extension module (skips linking against libpython.so)
# "abi3-py39" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.9
pyo3 = { version = "0.28.2", features = ["extension-module", "abi3-py39"] }

The [lib] table names the compiled library _core and makes it a cdylib, a shared library Python can import. The abi3-py39 feature selects the stable ABI (application binary interface, the contract that lets one compiled file load into many Python versions), so a single wheel covers Python 3.9 and later. The extension-module feature skips linking against libpython, as the comment says.

#[pymodule]
mod _core {
    #[pyfunction]
    fn distance(a: &str, b: &str) -> usize {
from editdist._core import distance

__all__ = ["distance", "closest"]

Both files are trimmed here. The Rust function distance lives in the _core module, and a comment in lib.rs repeats that the module name must match lib.name. __init__.py imports it and adds closest, plain Python that returns min(candidates, key=lambda c: distance(word, c)). The public API is distance and closest, and the tests call both.

Resolved 7 packages in 324ms
source = { editable = "." }
Cargo.lock
2 passed in 0.15s

These lines come from the first uv run. uv resolved seven packages, and the project appears in uv.lock with source = { editable = "." }, pointing at the source tree on disk. ls Cargo.lock found the Rust lock, which the first build created, and two tests passed. Each language now has its own lock file.

The first mistake in this setup has nothing to do with Nix yet.

Symptom: After the first uv run, the lab counted 164 paths that Git would add.

Cause: The .gitignore generated by uv init covers __pycache__/, build/, dist/, wheels/, *.egg-info and .venv. It says nothing about Rust's target/ directory or the compiled extension _core.abi3.so, which sits next to the Python code.

Fix: Append /target and *.so. After that, 14 files are tracked.

# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

# Rust build output and the in-place extension
/target
*.so

The last group is the addition. The ignore list matters for Nix as well: the Nix manual says that for a local Git flake without a ref or rev, files are fetched from the local path "as long as they have been added to the Git repository". What you ignore and what you stage decides what the build sees.

A  .gitignore
A  Cargo.lock
A  flake.nix
A  uv.lock

This is a trimmed view of git status --short after the fix, which listed 14 entries. Every lab build printed warning: Git tree '~/lab-2026-10-30/editdist' has uncommitted changes, because the lab worked with staged, uncommitted edits; the manual says a dirty tree gets a warning, and the builds still ran.

From uv.lock to a Nix virtual environment

A flake is a directory with a flake.nix that declares its inputs (other flakes, pinned in flake.lock) and its outputs (packages, checks and so on). A derivation names the inputs, build steps and output of one build, and the result lands in the Nix store under /nix/store. The uv2nix manual says packages are built individually but "aren’t really useful until they’re aggregated into a virtual environment". The flake below is the lab's final one with the Rust fix-ups and the check removed.

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    pyproject-nix = {
      url = "github:pyproject-nix/pyproject.nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    uv2nix = {
      url = "github:pyproject-nix/uv2nix";
      inputs.pyproject-nix.follows = "pyproject-nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    pyproject-build-systems = {
      url = "github:pyproject-nix/build-system-pkgs";
      inputs.pyproject-nix.follows = "pyproject-nix";
      inputs.uv2nix.follows = "uv2nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

Four inputs are needed: nixpkgs (the Nix package collection), pyproject.nix (the Python build helpers), uv2nix, and build-system-pkgs (common build tools such as maturin). Each follows line makes an input reuse the top-level nixpkgs, so the flake evaluates one nixpkgs rather than several differently pinned copies, and flake.lock records one revision for each.

  outputs = { nixpkgs, pyproject-nix, uv2nix, pyproject-build-systems, ... }:
    let
      inherit (nixpkgs) lib;
      system = "aarch64-darwin";
      pkgs = nixpkgs.legacyPackages.${system};

      workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
      overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };

      pythonSet =
        (pkgs.callPackage pyproject-nix.build.packages { python = pkgs.python312; })
        .overrideScope (lib.composeManyExtensions [
          pyproject-build-systems.overlays.wheel
          overlay
        ]);
    in
    {
      packages.${system}.default = pythonSet.mkVirtualEnv "editdist-env" workspace.deps.default;

This excerpt omits the closing braces. loadWorkspace reads pyproject.toml and uv.lock from the current directory, and uv2nix treats every project as a workspace project, even one with a single pyproject.toml. mkPyprojectOverlay "Takes uv.lock & creates an overlay for use with pyproject.nix builders". The sourcePreference = "wheel" setting prefers binary wheels, because they are "much more likely to “just work” while sdists", the source distributions, "require manual overrides more often". The manual adds that "uv doesn’t lock build systems", so pyproject-build-systems.overlays.wheel supplies the common ones. pythonSet layers both overlays over a Python 3.12 package set, and mkVirtualEnv with workspace.deps.default builds the environment. The lab tested only aarch64-darwin.

The naive flake is exactly the final flake minus the Rust fix-ups and the check, and nix build .#default -L failed on it. The -L flag prints build logs, which is why the quoted output has an editdist> prefix.

editdist>   File "/nix/store/zqvbq6qfg3m89csbl870c9j48qmw6id4-maturin-1.15.0/lib/python3.12/site-packages/maturin/__init__.py", line 80, in _get_env
editdist>     from puccinialin import setup_rust
editdist> ModuleNotFoundError: No module named 'puccinialin'
editdist> error: Failed to build `/nix/var/nix/builds/nix-80890-3078073423/czl5xlp8a0shkij79pp01liwman110bl-source`
editdist>   cause: The build backend returned an error
editdist>   cause: Call to `maturin.build_wheel` failed (exit status: 1)
editdist> 
editdist> hint: This error likely indicates that `[email protected]` depends on `puccinialin`, but doesn't declare it as a build dependency. If `editdist` is a first-party package, consider adding `puccinialin` to its `build-system.requires`. Otherwise, either add it to your `pyproject.toml` under:
nix build .#default -L  0.42s user 0.18s system 39% cpu 1.529 total
rc=1

Inside the Nix build, uv 0.13.5 is the build frontend and calls maturin 1.15.0, the backend. The traceback ends in maturin's _get_env failing to import a package nobody declared, and uv's hint suggests declaring it. The maturin source shows why the import happens at all.

def _get_env() -> dict[str, str] | None:
    if not os.environ.get("MATURIN_NO_INSTALL_RUST") and not shutil.which("cargo"):
        from puccinialin import setup_rust

        print("Rust not found, installing into a temporary directory")
        extra_env = setup_rust()
        return {**os.environ, **extra_env}

shutil.which("cargo") searches PATH, the list of directories a process searches for programs. If cargo is missing and the environment variable MATURIN_NO_INSTALL_RUST is unset, maturin imports a helper package named puccinialin and installs Rust into a temporary directory. The Nix build had no cargo, so the fallback ran and the helper was absent. The lab did not test setting that variable.

Symptom: The Nix build of a maturin package fails with ModuleNotFoundError: No module named 'puccinialin' and a hint to add it to build-system.requires.

Cause: There is no cargo on the build PATH, so maturin's fallback tries to download a Rust toolchain.

Fix: Do not follow the hint. Give the build a Rust toolchain from nixpkgs, as the next section does. Following the hint would download a toolchain during the build, which defeats the point of pinning one.

Cargo inside a Nix build

Vendoring means copying every crate the lock names into a local directory that Cargo reads instead of the crates.io index. A fixed-output derivation is a derivation whose output hash is known up front, which is what lets Nix fetch it. IFD, import from derivation, means Nix has to build something while it is still working out what to build. The first attempt adds only the toolchain:

31a32,38
>       # Fix-ups uv.lock cannot express: the Rust toolchain for maturin.
>       pyprojectOverrides = final: prev: {
>         editdist = prev.editdist.overrideAttrs (old: {
>           nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.cargo pkgs.rustc ];
>         });
>       };
> 
36a44
>           pyprojectOverrides

The overrideAttrs call changes the editdist derivation after uv2nix has created it, and nativeBuildInputs lists the tools the build needs, here pkgs.cargo and pkgs.rustc. The second hunk adds pyprojectOverrides to the overlay list, after the uv2nix overlay, so it sees the package it changes. With cargo on PATH, maturin skips its fallback, and the failure moves one step further.

editdist> warning: Invalid `SSL_CERT_FILE`. Path does not exist: /no-cert-file.crt. No default certificates will be trusted.
editdist>     Updating crates.io index
editdist> error: failed to get `pyo3` as a dependency of package `editdist v0.1.0 (/nix/var/nix/builds/nix-80978-2780233894/fglham9zzm8x9jm7ycscnmk6sp3pfg12-source)`
editdist>   [60] SSL peer certificate or SSH remote key was not OK (SSL certificate OpenSSL verify result: unable to get local issuer certificate (20))
nix build .#default -L  0.66s user 0.95s system 10% cpu 16.033 total
rc=1

Cargo tried the network, as the pyproject.nix manual predicts: Cargo "expects to be able to download dependencies at build-time". The build environment pointed at a certificate file that does not exist, so no certificates were trusted and the crates.io index download failed. The attempt reached the network only because the sandbox was off; the lab did not test a sandboxed builder, so the safe conclusion is that the build cannot rely on fetching crates.

The second step replaces the toolchain override with the vendoring helper.

32c32,35
<       # Fix-ups uv.lock cannot express: the Rust toolchain for maturin.
---
>       hacks = pkgs.callPackage pyproject-nix.build.hacks { };
> 
>       # Fix-ups uv.lock cannot express: vendor the crates listed in
>       # Cargo.lock and put cargo and rustc on the build PATH.
34,36c37
<         editdist = prev.editdist.overrideAttrs (old: {
<           nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.cargo pkgs.rustc ];
<         });
---
>         editdist = hacks.importCargoLock { prev = prev.editdist; };

The pyproject.nix manual describes hacks.importCargoLock as building a Cargo package "using rustPlatform.importCargoLock to fetch Rust dependencies", and notes that this mechanism uses IFD on non-local packages. Its argument list includes cargo and rustc, so the helper also supplies the toolchain, which is why the explicit override could go. The Nixpkgs manual says importCargoLock needs no hash unless git dependencies are used, and that it "fetches every dependency as a separate fixed-output derivation". Each crate is therefore pinned by Cargo.lock alone.

editdist> 🔗 Found pyo3 bindings with abi3-py3.9 support
editdist>    Compiling pyo3 v0.28.3
editdist>     Finished `release` profile [optimized] target(s) in 6.78s
editdist> ⚠️  Warning: Your library requires copying the above external libraries. Re-run with `--auditwheel=repair` to copy them into the wheel.
editdist> Successfully built dist/editdist-0.1.0-cp39-abi3-macosx_14_0_arm64.whl
nix build .#default -L  0.52s user 0.19s system 6% cpu 10.552 total
rc=0

This is a trimmed excerpt of the vendored build, which took 10.552 seconds and exited with 0. Cargo resolved pyo3 = "0.28.2" to 0.28.3, the version pinned in Cargo.lock, and read it from the vendor directory. The warning refers to the libiconv libraries the compiled file links against; the lab did not investigate it. The abi3 tag in the wheel name matches the abi3-py39 feature. The run line uses the environment that nix build left in ./result.

./result/bin/python -c 'from editdist import distance, closest; print(distance("kitten","sitting"), closest("deploi",["deploy","destroy","delay"]))'
3 deploy

The Rust function and the Python wrapper both work from the Nix-built environment.

A later experiment added strsim = "0.11" to Cargo.toml without touching the lock. The build failed with these lines:

editdist> error: no matching package named `strsim` found
editdist> location searched: directory source `/nix/var/nix/builds/nix-83440-1543111610/cargo-vendor-dir` (which is replacing registry `crates-io`)

Symptom: After adding a crate to Cargo.toml, the Nix build fails with `error: no matching package named strsim found and a location searched line naming the cargo-vendor-dir` directory.

Cause: The vendor directory is built from Cargo.lock, and the lock was never updated, so the crate is not in it even though Cargo.toml is correct.

Fix: Update the lock on a machine with network access and commit it. The lab ran cargo update --workspace with the machine's own cargo 1.99.1, outside Nix.

$ cargo update --workspace
    Updating crates.io index
      Adding strsim v0.11.1
[exit 0]
$ git diff --stat Cargo.lock
 Cargo.lock | 7 +++++++
 1 file changed, 7 insertions(+)
$ nix build .#default
building '/nix/store/za7iaz32ysm6s501mx11nsxr0xl2h87p-editdist-0.1.0.drv'...
[exit 0]

The lock gained seven lines for strsim v0.11.1, and the rebuild succeeded. The network step happens once, on a developer machine, and the lock then carries everything the vendoring step needs.

Tests as a flake check, and the drift Nix cannot see

A flake check is a derivation listed under a flake's checks output; nix flake check builds all of them and fails if one fails. A dependency group is a named list of dependencies in pyproject.toml, such as dev, kept apart from the dependencies of the installed package. The default environment leaves the dev group out, so it has no pytest:

./result/bin/python -m pytest
/nix/store/fikndzqr109i3mjmm17a779r43n4jlwn-editdist-env/bin/python3.12: No module named pytest
rc=1

That is correct, since workspace.deps.default leaves the dev group out. The uv2nix manual says mkVirtualEnv also accepts an explicit set naming each package and what to enable for it, such as { hello-world = [ ]; }. The lab put "dev" in that list to build a second environment for the check.

      checks.${system}.pytest =
        let
          testEnv = pythonSet.mkVirtualEnv "editdist-test-env" { editdist = [ "dev" ]; };
        in
        pkgs.runCommand "editdist-pytest" { nativeBuildInputs = [ testEnv ]; } ''
          cp -r ${./tests} tests
          pytest -q -p no:cacheprovider tests
          touch $out
        '';

runCommand creates a derivation that runs a shell script. Listing testEnv in nativeBuildInputs puts its bin directory on PATH, which is how pytest is found. The script copies tests/ out of the flake source, so that directory must be staged in Git. -p no:cacheprovider stops pytest writing a cache directory, and touch $out creates the output, because a derivation must produce one to count as built.

editdist-pytest> 2 passed in 0.14s
✅ checks.aarch64-darwin.pytest
nix flake check -L  0.42s user 0.09s system 4% cpu 11.361 total
rc=0

The check passed with the same two tests as the plain uv run. For development shells built with uv2nix, the manual warns against uv run, which "makes uv provision it’s own virtual environment"; the lab did not build one.

Now the drift that Nix cannot see. A pull request added rich>=13 to dependencies in pyproject.toml and left uv.lock alone:

@@ -4,7 +4,7 @@ version = "0.1.0"
-dependencies = []
+dependencies = ["rich>=13"]
$ nix build .#default
[exit 0]
$ grep Requires-Dist result/lib/python3.12/site-packages/editdist-0.1.0.dist-info/METADATA
Requires-Dist: rich>=13
[exit 0]
$ ./result/bin/python -c 'import rich'
ModuleNotFoundError: No module named 'rich'
[exit 1]

Symptom: The Nix build passes, the wheel METADATA says Requires-Dist: rich>=13, and import rich still fails with ModuleNotFoundError: No module named 'rich'.

Cause: uv2nix builds what uv.lock says, and the lock was not regenerated, so Nix never heard of rich. The wheel declares the requirement, but nothing installs it.

Fix: Run uv lock --check in CI before the Nix build. The uv documentation says it is equivalent to --locked for other commands, and the CLI reference says it asserts that uv.lock would remain unchanged after a resolution.

$ uv lock --check
Resolved 10 packages in 228ms
error: The lockfile at `uv.lock` needs to be updated, but `--check` was provided.

hint: To update the lockfile, run `uv lock`.
[exit 1]
$ uv lock --check   # after reverting
Resolved 7 packages in 2ms
[exit 0]

With the rich change in place the resolution finds 10 packages instead of 7, so the lock is out of date and the command exits with 1, failing the CI step. After reverting, it exits with 0. Without this step, the missing dependency surfaces only when someone imports the module.

Putting it together

  1. Ignore target/ and *.so, so build output never enters the tree that Nix sees.
  2. Commit uv.lock, Cargo.lock and flake.lock, and stage new files, since a flake in Git sees only what has been added.
  3. In CI, run uv lock --check first.
  4. Then run nix flake check -L.
  5. Then run nix build.

The lab also measured a cost. After a README-only edit, the derivation path of editdist changed:

before: /nix/store/g6xifwr46j2njzwqcnzfyb70lwr15x4j-editdist-0.1.0.drv
after README edit: /nix/store/hvzv0m1swhjbqih61j3xjflqais5ax42-editdist-0.1.0.drv

Any file change in the repository therefore rebuilds the Rust extension. The uv2nix manual says filtering sources at the workspace root "technically works, but is incorrect and causes issues with editable packages", and points to its source filtering docs, which the lab did not try. The runtime environment is large too:

/nix/store/vgwdsgh9h5wrchrs4l5nx1g1z4h8acf1-python3-3.12.15                        	   1.3 GiB
/nix/store/fikndzqr109i3mjmm17a779r43n4jlwn-editdist-env                           	   1.3 GiB

The environment is 1.3 GiB, nearly all of it the python3-3.12.15 closure it depends on. The lab did not look into reducing it.

Check yourself

Question 1
Why did puccinialin appear in an error when the project never mentions it?
tap to reveal
With no cargo on the build PATH, maturin's fallback tried to install Rust through that helper package. The fix is a nixpkgs toolchain, not the hint.
Question 2
Why did the build fail on strsim even though Cargo.toml was correct?
tap to reveal
The vendor directory is built from Cargo.lock, and the lock had not been updated, so the crate was missing from it.
Question 3
Why did the build pass after rich>=13 was added to pyproject.toml?
tap to reveal
uv2nix builds what uv.lock says, and the lock was not regenerated. uv lock --check in CI catches this.
Question 4
What does workspace.deps.default leave out?
tap to reveal
The dev dependency group, which is why the default environment had no pytest and the check built a second environment with { editdist = [ "dev" ]; }.

Sources

  1. uv2nix manual
  2. pyproject.nix manual (building Cargo packages, hacks.importCargoLock)
  3. Nixpkgs manual: Rust (importCargoLock)
  4. uv docs: creating projects (maturin build backend)
  5. uv docs: locking and syncing (uv lock --check)
  6. uv command reference
  7. maturin user guide: project layout
  8. Nix reference manual 2.28: nix flake