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 |
ranges — httpx>=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, orpylock.<name>.tomlfor a named variant such aspylock.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
postgresextra 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.