How to use a uv lockfile for reproducible Python environments
Commit uv.lock to version control. It records the exact version and hash of every package your project depends on, including transitive dependencies, so uv installs the identical tree on every machine and every deployment. This guide shows how to create, commit, refresh, and troubleshoot the lockfile, and how to wire it into CI and Docker without silent drift.
Create your first lockfile
Start a new project and add dependencies:
uv init --bare reproducible-demo
cd reproducible-demo
uv add requests pandasuv writes uv.lock with pinned versions of requests, pandas, and every transitive dependency. uv.lock is a universal lockfile: one file resolves across operating systems, architectures, and Python versions. A teammate on Windows and a Linux CI runner install identical versions from it.
Important
Never edit uv.lock by hand. uv regenerates it the next time you run a command that locks, so manual edits are overwritten.
Sync your environment from the lockfile
uv sync makes your virtual environment match the lockfile:
uv syncThe behavior depends on the lockfile state:
- If
uv.lockmatchespyproject.toml, uv installs the exact versions from the lockfile. - If the lockfile is stale or missing, uv re-resolves, rewrites
uv.lock, then installs.
That automatic re-locking is convenient in development but dangerous in CI, where a silent update defeats the point of a lockfile. Two flags turn it off:
uv sync --locked # error if uv.lock is out of date with pyproject.toml
uv sync --frozen # install from uv.lock as-is, never checking pyproject.toml--locked asserts the lockfile is current; --frozen asserts nothing and installs the lockfile as it stands. Use --locked when you want the build to fail on drift. Use --frozen when you have already validated the lockfile and want the fastest install with no resolution step.
Re-lock when resolution inputs change
Re-lock whenever the inputs to resolution change:
- After editing dependencies in
pyproject.tomlby hand (uv addanduv removere-lock for you). - After pulling changes that touched
pyproject.tomloruv.lock. - To pick up security or feature releases with
uv lock --upgrade, even whenpyproject.tomlis unchanged.
To refresh the lockfile without touching the environment, run uv lock on its own. To verify a lockfile is current without changing anything, run:
uv lock --checkThis exits non-zero if uv.lock no longer matches pyproject.toml, which makes it a fast pre-deploy gate.
uv has a second --check that answers a different question. uv lock --check asks whether the lockfile is current. uv sync --check asks whether .venv matches the lockfile, and reports what it would install without installing anything. Here the demo project’s .venv was recreated empty with uv venv --clear while uv.lock stayed current:
$ uv sync --check
Would use project environment at: .venv
Resolved 11 packages in 1ms
Found up-to-date lockfile at: uv.lock
Would install 10 packages
+ certifi==2026.7.22
+ charset-normalizer==3.4.9
+ idna==3.18
+ numpy==2.5.1
+ pandas==3.0.5
+ python-dateutil==2.9.0.post0
+ reproducible-demo==0.1.0 (from file:///path/to/reproducible-demo)
+ requests==2.34.2
+ six==1.17.0
+ urllib3==2.7.0
The environment is outdated; run `uv sync` to update the environment
uv sync --check re-resolves pyproject.toml before it compares anything, so the third line reports on the lockfile as well: Found up-to-date lockfile at: here, and Would update lockfile at: uv.lock when the lockfile has drifted too. Neither case writes anything, and an outdated environment exits non-zero.
Upgrade dependencies safely
uv lock keeps existing versions pinned unless you explicitly allow upgrades. Update everything to the latest compatible versions:
uv lock --upgradeUpgrade a single package, or pin it to a target version, while leaving the rest of the tree untouched:
uv lock --upgrade-package requests
uv lock --upgrade-package 'pandas==2.2.3'Upgrade every package in one dependency group:
uv lock --upgrade-group devPreview any upgrade before writing the lockfile with --dry-run:
uv lock --upgrade-package requests --dry-run$ uv lock --upgrade-package requests --dry-run
Resolved 6 packages in 83ms
Update requests v2.32.3 -> v2.34.2
All three upgrade flags also work on uv sync to update and install in one step, for example uv sync --upgrade-package requests.
Lock the same versions in CI and Docker
In CI, install strictly from the committed lockfile and fail if it has drifted:
uv sync --lockedIf --locked fails, someone changed pyproject.toml without re-locking. Run uv lock locally and commit the updated uv.lock.
In Docker, copy the lockfile and pyproject.toml before the source code so the dependency layer caches independently of application changes:
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project
COPY . .
RUN uv sync --locked--no-install-project installs dependencies but not your own package, so editing source code invalidates only the final cheap layer, not the expensive dependency layer above it. For a full container walkthrough, see How to use uv in a Dockerfile.
Hand the lockfile to a tool that cannot read it
Scanners, base images without uv, and plain pip do not understand uv.lock. Render it into a format they do:
uv export --format requirements.txt --no-emit-project -o requirements.txtThe output pins every version with hashes and a # via comment naming the requester. --no-emit-project leaves out the -e . line for your own package. --format pylock.toml writes the standardized lockfile format any compliant installer can consume, and --format cyclonedx1.5 writes an SBOM. Treat the exported file as a build artifact: uv.lock stays the source of truth.
Fix common lockfile errors
“The lockfile needs to be updated, but --locked was provided.” The committed uv.lock no longer matches pyproject.toml:
$ uv sync --locked
Resolved 15 packages in 4ms
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
hint: To update the lockfile, run `uv lock`.
Run uv lock, commit the result, and the CI step passes. uv lock --check prints the same message with --check in place of --locked.
“Unable to find lockfile at uv.lock.” The lockfile was never committed, or .gitignore swallowed it. Both --locked and --frozen fail the same way:
$ uv sync --frozen
error: Unable to find lockfile at `uv.lock`, but `--frozen` was provided. To create a lockfile, run `uv lock` or `uv sync` without the flag.
Run uv lock and commit the result.
“No solution found when resolving dependencies.” Two requirements cannot be satisfied at once:
$ uv add 'urllib3>=2' 'botocore==1.29.0'
× No solution found when resolving dependencies:
╰─▶ Because botocore==1.29.0 depends on urllib3>=1.25.4,<1.27 and your
project depends on botocore==1.29.0, we can conclude that your project
depends on urllib3>=1.25.4,<1.27.
And because your project depends on urllib3>=2, we can conclude that
your project's requirements are unsatisfiable.
uv names the exact packages and the conflicting ranges. Loosen one constraint (here, drop the urllib3>=2 pin and let botocore choose), or upgrade the package whose pin is forcing the old range. For the full workflow (reading the derivation chain, uv tree --invert, overrides, and constraints), see How to debug uv dependency resolution failures.
A teammate’s lockfile won’t reproduce. If uv sync re-resolves to different versions on another machine, the lockfile was probably never committed, or someone ran a plain uv sync that silently updated it. Confirm uv.lock is tracked in git and switch CI to uv sync --locked so drift fails loudly instead of slipping through.
Commit uv.lock
Commit it for any application or service. The committed lockfile is what guarantees that your laptop, your teammates’ machines, CI, and production all install the identical dependency tree.
The one exception is libraries published to PyPI. Downstream installers resolve their own versions from your pyproject.toml ranges, so your uv.lock governs only your own development environment. Commit it anyway for reproducible local work, but know it has no effect on people who install your package.