Skip to content
← All articles

Lockfiles and reproducible installs

Without a lockfile your deploy is a fresh resolution against a mutable index — the artefact you tested is not the artefact you shipped. Without hashes, a compromised index silently changes your code.

Deploy the same commit twice, a week apart. If the two deployments can contain different code, you do not have a build — you have a query against a mutable database, run at deploy time, by a resolver.

That is what happens with dependencies = ["httpx>=0.27"] and no lockfile. The declaration is a range; the installer picks whatever satisfies it today. Nothing in your repository changed. Your artefact did.

Three layers, three jobs

Artefact Contains Job
pyproject.toml rangeshttpx>=0.27 what this project is compatible with
lockfile an exact resolution — every package, version, and hash what we tested
the environment installed files what is running

Each answers a different question and none substitutes for another. A lockfile is not a stricter pyproject.toml; it is the output of resolving one, recorded so the resolution never has to happen again.

uv.lock and PEP 751 pylock.toml

uv.lock is uv’s native format. It is universal: one file records the resolution for every platform and Python version your requires-python allows, so a Linux CI machine and a macOS laptop install the same versions. Check it into version control. Never hand-edit it — it is a build output, and the thing that regenerates it is uv lock.

pylock.toml is PEP 751, the interoperable standard: a lockfile format that any installer can consume, so a lockfile is no longer a lock-in to the tool that produced it. Details worth knowing:

  • The file must be named pylock.toml, or pylock.<name>.toml for a named variant such as pylock.dev.toml.
  • It is designed so an installer needs no resolution at install time — it reads the file and installs, which is both faster and deterministic.
  • It extends environment markers to cover extras and dependency groups, so one file can express “these packages, but only when the postgres extra is requested”.

pip lock exists and is experimental. Read its caveat carefully: the output is only guaranteed to be valid for the current Python version and platform. That is a per-machine lockfile, which is useful in a container build and misleading in a repository shared across operating systems.

💡Someone commits the output of pip freeze > requirements.txt and calls it a lockfile. Give three concrete reasons it is not. click to reveal

It records what happens to be installed, not what was resolved. If your virtualenv has a package you installed by hand last month, or is missing one you removed without updating anything, the file captures that. It is a snapshot of a machine, not of a resolution.

It says nothing about markers. There is no way to express “this package only on Windows” or “this one only on Python < 3.13”. So a freeze taken on macOS installs macOS-only wheels’ requirements on Linux, or omits packages Linux needs — and you find out in the deploy.

It cannot express per-platform resolutions at all. A real lockfile records one resolution valid across the whole matrix you support. A freeze records one environment, and there is no schema in which to write the others.

A fourth, if you want it: no hashes by default, so it does not protect against an index serving different bytes for the same version.

Hashes: what they actually protect against

A version number is a name, not a fingerprint. If an index — or a proxy, or a cache, or a compromised maintainer account — serves different bytes for httpx==0.27.2, a version pin does not notice. A hash does:

httpx==0.27.2 \
    --hash=sha256:3d3f9f3c0d2e0f0a2b8b8f6a...

Two rules make this usable:

It is all-or-nothing. The moment any requirement carries a --hash, pip requires hashes for every requirement in the file, including transitive ones. This is deliberate: a partially-hashed install gives a false sense of integrity, because the unhashed package can pull in anything.

--require-hashes makes it explicit. Turn it on in CI so a requirements file that quietly loses its hashes fails the build instead of degrading to trust-the-index.

The CI discipline that people get wrong

Three uv behaviours that look interchangeable and are not:

Command Behaviour
uv sync if the lockfile is out of date with pyproject.toml, re-lock silently, then install
uv sync --locked if the lockfile is out of date, fail
uv sync --frozen install from the lockfile as-is, without checking it against pyproject.toml

CI should use --locked. The default is the dangerous one: a pull request that adds a dependency to pyproject.toml and forgets to commit the updated lockfile will pass, because CI regenerated the lock in memory, tested that resolution, and threw it away. Production then installs from the stale committed lockfile — a different tree from the one that went green.

--frozen is right in a container build where you know the lockfile is authoritative and want to skip even reading pyproject.toml. It is wrong as a CI default, because it will happily install a lockfile that no longer matches your declared dependencies at all.

💡A deploy pulls a package version that no test ever ran against, and nobody changed the lockfile. Name the two most likely causes. click to reveal

CI never checked the lockfile. The pipeline ran a plain uv sync (or pip install -e .), which resolved fresh against the index. The branch went green against a resolution that exists nowhere on disk. Production installed something else. The fix is --locked, and the evidence is that re-running CI on the same commit can produce different results.

The deploy does not install from the lockfile. A Dockerfile with RUN pip install -r requirements.txt or RUN pip install . next to a perfectly good uv.lock is extremely common — the lockfile exists, is committed, is up to date, and is simply not on the install path.

Both have the same shape: the lockfile is treated as documentation rather than as the input to the install. The check that catches either one is boring and decisive — build the image, print the installed versions, and diff them against the lockfile.