Project Setup

Locking and Syncing a Python CLI’s Dependencies with uv

Understand uv.lock in a CLI project: lock vs sync, --locked and --frozen in CI, targeted upgrades, reading lock diffs and dependency groups.

Updated

uv.lock is the most important file in a uv-managed CLI project that nobody reads. It records the exact version, source and file hashes of every package your project can install — on every platform and Python version you support — so that a test run on a laptop, a CI job and a release build all see the same dependency set. Two commands work with it: uv lock decides what the versions are, and uv sync makes a virtual environment match them. Most of the confusing moments with uv — CI that quietly installs something different, an upgrade that touches forty packages, a merge conflict in a 3,000-line file — come from not knowing which command does what and which flags to use where. This guide sorts that out for a CLI project. It belongs to the uv topic.

Prerequisites

  • uv installed (curl -LsSf https://astral.sh/uv/install.sh | sh or your package manager).
  • A CLI project with a pyproject.toml, for example one created as in uv init vs poetry init for CLI tools.

Two files, two jobs

pyproject.toml says what your project accepts: ranges such as typer>=0.12 and httpx>=0.27,<1. Those ranges are what end users get when they install your CLI from PyPI, so they should be permissive enough to receive fixes. uv.lock says what your project uses: one resolved, hashed version of each package, chosen within those ranges. Committing it means every developer and every CI run starts from the same set.

pyproject.toml and uv.lock A comparison of what pyproject.toml and uv.lock each record for a command line tool project and who uses each. pyproject.toml and uv.lock File Records Used by pyproject.toml accepted ranges end users via PyPI uv.lock one version + hashes each developers, CI, releases the .venv what is installed now uv run uv lock turns ranges into versions; uv sync turns versions into an environment.

The lock file is universal: uv resolves once for all platforms and Python versions declared by requires-python, recording platform-specific packages with environment markers. A single uv.lock therefore serves a Linux CI runner, a macOS laptop and a Windows tester — unlike a requirements.txt compiled on one machine.

The recipe

Day to day

uv add httpx                 # add to pyproject.toml, re-lock, sync the venv
uv add --dev pytest respx    # development dependency group
uv remove rich               # remove, re-lock, sync
uv run mytool --help         # sync if needed, then run inside the project venv

uv add, uv remove and uv run keep both files and the environment consistent automatically: they update pyproject.toml, re-lock if the requirements changed, and sync the virtual environment. For most daily work you never call uv lock or uv sync directly.

In CI: --locked

uv sync --locked             # fail if uv.lock does not match pyproject.toml
uv run --locked pytest

Without a flag, uv sync re-locks when pyproject.toml has changed since the lock was written — convenient locally, dangerous in CI, where it means testing a resolution nobody reviewed. --locked turns that situation into an error: "The lockfile at uv.lock needs to be updated". The contributor then runs uv lock and commits the result, and the change shows up in review.

--frozen is the other strict flag, and the difference matters. --frozen uses the lock file as is without checking it against pyproject.toml at all. Use it where pyproject.toml may legitimately differ — in a Docker build stage that copies only the lock file first for layer caching, or when exporting — and use --locked everywhere you want the consistency check.

Plain, --locked or --frozen? How uv sync behaves with no flag, with locked and with frozen when pyproject.toml has changed since the lock was written. Plain, --locked or --frozen? Command If pyproject.toml changed Use it uv sync re-locks silently local development uv sync --locked fails with a clear error CI uv sync --frozen ignores the change Docker layers, exports --locked checks consistency; --frozen deliberately skips the check.

Upgrading on purpose

uv lock --upgrade-package httpx          # move one package (and what it needs)
uv lock --upgrade-package 'rich<14'      # move it, but not past a bound
uv lock --upgrade                        # re-resolve everything to the newest allowed
uv tree --outdated --depth 1             # see what is behind

uv lock on its own never upgrades anything that still satisfies pyproject.toml; it only resolves what is new or no longer valid. That stability is deliberate — upgrades happen when you ask. Targeted upgrades with --upgrade-package keep lock diffs small and reviewable, which is also the fastest way to apply a security fix found by auditing dependencies with pip-audit. A full --upgrade is best done on a schedule by a bot, as its own pull request, with the test suite as the judge.

To check without writing anything, uv lock --check exits non-zero if the lock is out of date — handy in a pre-commit hook so the mistake is caught before pushing.

Dependency groups for a CLI

Development tools do not belong in the dependencies users install. uv's dependency groups (PEP 735) keep them separate in pyproject.toml:

[project]
name = "mytool"
dependencies = ["typer>=0.12", "httpx>=0.27,<1"]

[project.optional-dependencies]
yaml = ["pyyaml>=6"]                     # an extra users can opt into

[dependency-groups]
dev = ["pytest>=8", "respx>=0.21"]
lint = ["ruff>=0.6", "mypy>=1.11"]
docs = ["mkdocs-material>=9"]

All groups are locked together, so the lock covers everything, and syncs choose what to install: uv sync installs the project plus the dev group by default; uv sync --group lint adds linters; uv sync --no-dev gives exactly what users get, which is what a release build or a smoke test of the built wheel should use. Extras are different: they are published with your package and users install them with mytool[yaml] — see optional dependencies and extras for CLIs.

Reading a lock diff

A lock change in a pull request is worth thirty seconds of attention. Each package appears as a [[package]] table with its name, version, source and hashes; a diff of a targeted upgrade looks like this:

 [[package]]
 name = "httpx"
-version = "0.27.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "…/httpx-0.27.2.tar.gz", hash = "sha256:f7c2…" }
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "…/httpx-0.28.1.tar.gz", hash = "sha256:75e9…" }

Three things are worth checking. Unexpected packages — a new name you did not add means a dependency gained a dependency; make sure it is the package you think it is. Changed sources — a package moving from PyPI to another index or a git URL deserves a question. Size of the change — a "small" upgrade that touches dozens of packages usually means a range was too loose or the wrong command was used.

A stale lock caught in CI Terminal session where uv sync with locked fails because pyproject.toml changed, then succeeds after running uv lock. A stale lock caught in CI bash $ uv sync --locked error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. $ uv lock && git add uv.lock Resolved 24 packages in 41ms $ uv sync --locked Audited 24 packages in 2ms The fix is one command, and the lock change shows up in review.

UX considerations

The "users" here are your contributors, and a few conventions make the lock painless for them:

  • Never hand-edit uv.lock. Resolve merge conflicts by taking either side and running uv lock again; uv rebuilds a consistent file from pyproject.toml.
  • Mark it generated in .gitattributes (uv.lock linguist-generated=true) so code review tools collapse it by default, while still showing it on demand.
  • Write the commands in CONTRIBUTING.md. "Use uv add, never pip install; CI runs uv sync --locked." One paragraph prevents most lock-related CI failures.
  • Pin uv itself in CI (astral-sh/setup-uv with a version:) so a new uv release cannot change resolution behaviour mid-sprint; upgrade it deliberately like any other tool.
  • Keep requires-python honest. The lock is resolved for every Python version it allows; claiming >=3.8 when you only test 3.11+ makes resolution harder and the lock larger. Pinning the Python version for a CLI covers the trade-off.

Testing the behaviour

The behaviour to guarantee is that CI fails when the lock is stale. Add a job step and, once, prove it works:

# .github/workflows/test.yml (excerpt)
      - uses: astral-sh/setup-uv@v6
        with:
          version: "0.8.x"
          enable-cache: true
      - name: Check the lock file is current
        run: uv lock --check
      - name: Install exactly the locked set
        run: uv sync --locked --all-groups
      - run: uv run --locked pytest

To see the failure, change a version bound in pyproject.toml on a branch without running uv lock, push, and confirm the "Check the lock file" step fails with a message naming uv.lock. Combined with caching uv dependencies in CI, the strict install adds almost no time to the job.

Conclusion

uv lock decides versions; uv sync applies them. Let uv add, uv remove and uv run keep everything consistent while you work; use --locked in CI so stale locks fail loudly; reserve --frozen for the few places that must ignore pyproject.toml; upgrade deliberately with --upgrade-package; and keep development tools in dependency groups so uv sync --no-dev reproduces exactly what users get. The lock file then does its job quietly: everyone, everywhere, tests the same thing.

Frequently asked questions

Should a CLI commit uv.lock?

Yes. Even though users installing from PyPI resolve from your ranges instead, the lock makes development, CI and release builds reproducible, and it is the source for SBOMs, audits and hashed exports. Libraries debate this; applications and CLIs should commit it.

Why did uv run change my lock file?

Because pyproject.toml changed — someone edited a bound by hand, or a merge brought in a new dependency — and uv run re-locks to keep things consistent. Use uv run --locked (or set UV_LOCKED=1) where that should be an error instead.

How do I get a requirements.txt for tools that need one?

uv export --format requirements.txt --no-dev -o requirements.txt writes a fully pinned, hashed file from the lock. Regenerate it rather than editing it, as described in pinning dependencies with hashes.

What does --inexact do?

By default uv sync makes the environment exactly match the lock, removing packages that are not in it. --inexact leaves extra packages alone — occasionally useful when you installed a debugging tool by hand, but CI should always use the default exact sync.