uv Locked vs Frozen: Stop Python Dependency Drift in CI

uv Locked vs Frozen: Stop Python Dependency Drift in CI Python

Use uv sync --locked in CI when you want a mismatch between pyproject.toml and uv.lock to stop the build. Use --frozen only when you intentionally want to consume the existing lockfile without checking whether it still matches the project's dependency declarations. Both avoid rewriting the lockfile, but they provide different guarantees.

That distinction is easy to miss: a successful frozen installation can omit a dependency you just added to pyproject.toml. This guide reproduces that failure mode, then builds a small dependency-checking workflow around it.

The examples were checked on September 5, 2026 with uv 0.12.9 and CPython 3.12.14 on Windows x64. The September 1 uv release also added --no-locked and --no-frozen overrides for environment-configured lock modes. Those options are useful for deliberate maintenance, not for making a failing CI check green.

The Three Files Have Different Jobs

Keep these responsibilities separate:

  • pyproject.toml expresses project metadata and dependency requirements.
  • uv.lock records the resolved dependency graph. Commit it for a uv-managed application.
  • .venv is the installed environment. Recreate it; do not commit it.

A lockfile improves dependency reproducibility, but it is not a complete build artifact. Python version, operating system, native libraries, build tools, and platform-specific dependency selections can still differ. Pin the interpreter and test the deployment platforms you actually support.

The uv project workflow is also distinct from its pip-compatible commands. Installing something manually with uv pip install does not make it a declared project dependency. Use uv add for that purpose.

Create a Small Reproducible Project

In an empty directory, create pyproject.toml:

[project]
name = "lock-demo"
version = "0.1.0"
requires-python = ">=3.12,<3.13"
dependencies = ["idna==3.10"]

The intentionally narrow Python range and exact package version make this experiment repeatable. They are test inputs, not recommendations that every application should use those exact constraints.

Create test_smoke.py:

import importlib.metadata
import unittest

import idna


class SmokeTest(unittest.TestCase):
    def test_idna_version(self):
        self.assertEqual(importlib.metadata.version("idna"), "3.10")

    def test_domain_encoding(self):
        self.assertEqual(idna.encode("bücher.example"), b"xn--bcher-kva.example")


if __name__ == "__main__":
    unittest.main()

With uv installed and a compatible Python available, run:

uv --version
uv lock
uv sync --locked
uv run --locked python -m unittest -v

For this experiment, both tests passed. The sample has no build-system declaration, so uv installs its dependencies but does not install the root project as a package. That is sufficient for running the test file from the project directory.

Keep the Toolchain Explicit

Record the uv version in CI rather than silently accepting whatever is preinstalled on a runner. Likewise, choose the same Python patch version for local verification and the CI job when exact interpreter reproducibility matters.

The tested setup used a separately downloaded uv binary and an isolated cache and virtual environment. It did not change the system Python installation.

Reproduce a Stale Lockfile

After the first successful run, edit only the dependency list:

dependencies = ["idna==3.10", "packaging==25.0"]

Do not run uv add or uv lock yet: the point is to leave the manifest and lockfile intentionally out of sync.

Now run:

uv sync --locked

In the test, uv exited with code 1 because the lockfile needed an update. Its SHA-256 remained unchanged. This is the behavior wanted at a CI boundary: the repository claims one dependency set, while its reviewed lockfile describes another.

Next run:

uv sync --frozen

That command exited with code 0 and preserved the same lockfile. However, packaging was not installed.

To inspect the environment without triggering another automatic sync, invoke its Python directly. On Windows:

.\.venv\Scripts\python.exe -c "import importlib.util; print(importlib.util.find_spec('packaging') is not None)"

On Linux or macOS, the corresponding executable is .venv/bin/python. The tested Windows output was False.

Why a Successful Frozen Sync Is Not Enough

The successful exit confirmed installation from the old snapshot, not agreement with the edited manifest. An application importing the newly declared package could still fail later.

Do not switch from --locked to --frozen merely to suppress this error. Review the manifest change, regenerate the lockfile intentionally, inspect the dependency diff, and run tests again.

The Actual Test Results

The checks below were executed with uv 0.12.9, CPython 3.12.14, and a disposable Windows x64 environment:

  • Initial uv lock and uv sync --locked: succeeded; idna 3.10 was installed.
  • Initial smoke test: 2 tests passed.
  • Added packaging==25.0 to the manifest without regenerating the lockfile.
  • Stale uv sync --locked: exit 1; the lockfile hash did not change.
  • Stale uv sync --frozen: exit 0; the same lockfile hash remained; packaging was absent.
  • Intentional relock and locked sync: succeeded; packaging 25.0 was installed.
  • Smoke test after recovery: 2 tests passed again.

This was a correctness experiment, not a benchmark. Network and installation timings are not presented as a performance comparison.

Use the New Override Only for Maintenance

uv 0.12.9 added explicit ways to disable lock modes inherited from UV_LOCKED and UV_FROZEN for one invocation.

For example, a PowerShell session may enforce locked behavior by default:

$env:UV_LOCKED = "1"
uv lock --no-locked
Remove-Item Env:UV_LOCKED
uv sync --locked
uv run --locked python -m unittest -v

This exact override was tested after creating the stale manifest. It allowed the deliberate relock, and the subsequent locked sync installed packaging 25.0. In a real maintenance task, review and commit the resulting uv.lock change together with the manifest change.

The analogous --no-frozen option disables an inherited frozen setting. It was verified in the official release notes, but not separately executed in this experiment.

Do not add either override to an ordinary CI test job. Its job is to validate the committed state, not repair it. Also inspect inherited environment variables when command behavior differs between a terminal and CI.

A Minimal CI Contract

For this sample, the core CI commands are:

uv sync --locked
uv run --locked python -m unittest -v
git diff --exit-code -- pyproject.toml uv.lock

Before these commands, check out the repository and install the intended Python and uv versions. Astral's official setup-uv integration supports a specified uv version and dependency caching. Pin third-party Actions to reviewed commit SHAs under your repository's supply-chain policy.

The dependency-sync and Python-test commands were exercised locally. The Git diff line is an additional repository guard; a hosted GitHub Actions job was not launched for this article.

Remember that the environment is part of the contract too. uv sync is exact by default and removes undeclared packages, while uv run is inexact by default. An initial explicit sync helps expose tests that accidentally rely on a package left behind from earlier work.

Separate Updates from Verification

Use a dedicated maintenance change to update dependencies:

uv lock --upgrade-package idna
uv sync --locked
uv run --locked python -m unittest -v

That update still respects the requirements in pyproject.toml. With idna==3.10 in this example, it cannot advance to another version. Broaden or change the constraint intentionally when that is the desired maintenance action, and update the version assertion in the sample test accordingly.

A routine CI run should not contain uv lock --upgrade. Otherwise the dependency graph being tested can differ from the one reviewed in the pull request.

When Frozen Mode Is Appropriate

Frozen mode can be useful in staged builds where a trusted lockfile is available before all project metadata has been copied. It can also fit a controlled installation step whose inputs were validated earlier.

The important question is where freshness was checked. If nothing verifies the complete manifest against the lockfile, frozen mode has not established that agreement for you.

For most application CI jobs, use --locked as the default. Reserve --frozen for a documented build constraint, keep upgrades deliberate, and treat a stale-lock failure as useful feedback rather than a nuisance to bypass.

Official References

Comment

Copied title and URL