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 | shor 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.
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.
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.
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 runninguv lockagain; uv rebuilds a consistent file frompyproject.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, neverpip install; CI runsuv sync --locked." One paragraph prevents most lock-related CI failures. - Pin uv itself in CI (
astral-sh/setup-uvwith aversion:) so a new uv release cannot change resolution behaviour mid-sprint; upgrade it deliberately like any other tool. - Keep
requires-pythonhonest. The lock is resolved for every Python version it allows; claiming>=3.8when 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.