Fail a stale uv lockfile before a Python deployment

John Burns

A Python deployment can look reproducible right up to the moment its dependency declaration and lockfile disagree. pyproject.toml may request a different package version while uv.lock still describes the previous resolution. If CI creates an environment without checking that relationship first, the job can spend time building or downloading before it identifies the real problem. Worse, a workflow that updates the lockfile during deployment turns a reviewable source change into an environment-dependent side effect.

uv lock --check gives that boundary a small, explicit test. It resolves enough project metadata to decide whether the committed lockfile is current, but it does not rewrite it. A zero exit status says the dependency declaration and lockfile agree. A nonzero exit status tells the developer to update and review uv.lock before the deployment path continues.

This guide builds a disposable Python project with uv 0.12.10, proves the check passes for a current lockfile, then changes one pinned dependency and captures the stale-lock failure. The final result is a CI gate that catches declaration drift before uv sync --locked creates the deployment environment.

Keep two different guarantees separate

A lockfile answers a different question from a version constraint. The dependency list in pyproject.toml states what the project requests. uv.lock records the resolved package set, including transitive dependencies and artifact information. A project can have a syntactically valid dependency declaration and still have a stale lockfile after someone changes a constraint.

That distinction is easy to miss when a local command silently refreshes the lock. Updating it is appropriate during ordinary dependency work, when the resulting diff can be inspected and committed. It is not a good deployment default. A deployment should consume an already reviewed resolution, not decide which packages to select at runtime.

Use two commands for the two jobs:

uv lock --check
uv sync --locked

The first command is the gate. The second creates or updates the project environment while requiring the existing lockfile. Put the gate first. If the lock is stale, the job stops with a focused explanation instead of reaching installation with a changed dependency request.

Obtain a known uv binary for the test

The validation used uv 0.12.10, released September 4, 2026, on Linux amd64 with CPython 3.13.5. Download the archive and its adjacent checksum file from the project’s release page, then verify the archive before extracting it. Do this in a disposable directory or a managed tool location; do not replace a system package manager’s copy solely for a project test.

curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
  https://github.com/astral-sh/uv/releases/download/0.12.10/uv-x86_64-unknown-linux-gnu.tar.gz \
  --output uv-x86_64-unknown-linux-gnu.tar.gz
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
  https://github.com/astral-sh/uv/releases/download/0.12.10/uv-x86_64-unknown-linux-gnu.tar.gz.sha256 \
  --output uv-x86_64-unknown-linux-gnu.tar.gz.sha256
sha256sum -c uv-x86_64-unknown-linux-gnu.tar.gz.sha256
tar -xzf uv-x86_64-unknown-linux-gnu.tar.gz
./uv-x86_64-unknown-linux-gnu/uv --version

The release checksum and the GitHub release asset digest both identified the tested archive as SHA-256 173d95a0c32d18c896c46ba6fafbf3cf9c14ab74b033f81b76c883ef492a976b. The validation printed uv 0.12.10 (x86_64-unknown-linux-gnu). If your project pins another uv release, substitute its official release URL and checksum rather than copying this value.

Create and lock a small project

This example uses an exact idna version so that the expected resolution is unambiguous. A real application can use ranges where that is appropriate; the purpose of the gate is to ensure the committed lock represents whatever policy the project chose.

Create pyproject.toml in an empty working directory:

[project]
name = "lock-gate-demo"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
  "idna==3.11",
]

Run uv lock once as a deliberate source change:

uv lock
uv lock --check
uv sync --locked
.venv/bin/python -c 'import idna; print("idna", idna.__version__)'

The recorded run resolved two packages, created the environment, and installed idna==3.11. The important check was not that a virtual environment existed. uv lock --check also returned zero immediately after the lockfile was created, so the declaration and resolution were demonstrably aligned before installation.

Resolved 2 packages in 0.56ms
check-exit=0
Prepared 1 package in 125ms
Installed 1 package in 2ms
 + idna==3.11
idna 3.11

Timing is included only to identify the observed run, not as a benchmark. Resolution and installation time vary with the cache, network, interpreter, and package set.

Make the stale condition visible

Now change only the requested version in pyproject.toml; leave uv.lock untouched:

dependencies = [
  "idna==3.10",
]

Run the check again and preserve its exit code in CI:

set +e
uv lock --check
status=$?
set -e
printf 'lock-check exit=%s\n' "$status"
test "$status" -eq 0

The validation run returned exit status 1 and produced the following error:

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

hint: To update the lockfile, run `uv lock`.

That failure is the useful outcome. It proves that changing the declaration without refreshing the lockfile cannot pass the pre-deployment gate. Do not hide the error with || true, and do not run uv lock in the same deployment step to make the job green. The correct repair belongs in a reviewed dependency update:

uv lock
uv lock --check
uv sync --locked
.venv/bin/python -c 'import idna; print("idna", idna.__version__)'

In the retest, uv reported Updated idna v3.11 -> v3.10; uv lock --check returned zero; and uv sync --locked replaced the installed package with idna 3.10. This pairs a controlled failure with a controlled recovery without involving an application service, registry credential, or production interpreter.

Add the gate to continuous integration

Run the check in a clean checkout before tests that need the environment. Pin the uv version using the mechanism your CI platform already trusts, and keep the Python version explicit if it affects resolution.

uv lock --check
uv sync --locked
uv run pytest

The uv run line is only an example test command. Replace it with the project’s real test, lint, migration, or build step. If the project does not use pytest, do not add it merely because the gate does. The essential ordering is check, locked synchronization, then work that relies on the environment.

A shared uv cache can make repeated jobs faster, but it does not replace the gate. uv documents that its cache is append-only and designed for concurrent access, while environments are locked during installation. Treat the cache as an optimization. The committed uv.lock, code review, and --check result remain the evidence that a deployment is using the intended resolution.

For a release workflow, make the failure actionable: report that pyproject.toml and uv.lock are out of sync, link to the dependency-update procedure, and require a commit containing both files. That keeps selection of new packages in the normal review path and keeps deployment jobs deterministic.

Sources