Python Packaging #5 Dependency Locking: Lock Files and Reproducibility

5 min read

In #2 we watched a single requirements.txt collapse under the burden of serving as both “intent” and “snapshot,” and in #3 the intent moved into pyproject.toml. The modern form of the remaining half — the snapshot — is the lock file. The uv.lock that #4’s uv add kept quietly updating, we now open for real. Once you can read it, reproducibility, security, and upgrades are all explained by this one file.

Declaration and lock: the same information in different tenses #

The relationship in one sentence: pyproject.toml is “the range our code allows” (present and future); the lock file is “the world actually settled in the last resolution” (a record of the past).

pyproject.toml and uv.lock
# pyproject.toml — declaration
dependencies = ["django>=5.2,<6"]

# uv.lock — lock (excerpt)
[[package]]
name = "django"
version = "5.2.4"
dependencies = [
    { name = "asgiref" },
    { name = "sqlparse" },
]
wheels = [
    { url = "...", hash = "sha256:8c8b3..." },
]

The lock file holds three things the declaration never had: the exact version (5.2.4), every transitive dependency that never appeared in the declaration (asgiref and sqlparse get entries of their own), and the hashes of the distributions. Out of the many combinations satisfying django>=5.2,<6, resolution settled on one — and as long as this file exists, every machine installs the same combination. This is where #2’s “install-day lottery” disappears structurally.

Unlike a list frozen on one OS, uv.lock is platform-independent: macOS-only and Linux-only distributions are recorded together with their conditions, so one file covers every teammate and CI. That is why, unlike .venv, the lock file must be committed — it is the original source of reproducibility.

Hashes: supply-chain defense beyond reproducibility #

The sha256 hash on each distribution is more than an integrity check. At install time, the downloaded file is verified byte-for-byte identical to the file present at lock time — so the class of supply-chain attack where a package index is compromised or contents get swapped under the same version number is caught at the install step. Whether a locked version has newly disclosed vulnerabilities is a separate question: add one line running uvx pip-audit against the locked environment to the CI from Testing #7.

In CI and deployment: –frozen is the default #

The lock file’s value is completed in CI. The key flag is frozen:

Install exactly as locked
uv sync --frozen     # install exactly per the lock, never re-lock

--frozen means “if the lock and pyproject.toml disagree, fail — don’t fix it.” Without it in CI, when someone edits pyproject.toml and forgets to re-lock, CI quietly re-resolves and passes tests against an environment different from everyone’s local one. CI is where you verify, not where you resolve. Lock locally; CI only reproduces, frozen.

Upgrades: a routine, not an event #

The side effect of locking is neglect. Lock once and forget, and you receive neither security patches nor bug fixes while versions age. The balance is making upgrades a deliberate routine:

Upgrade
uv lock --upgrade-package django   # upgrade just one
uv lock --upgrade                  # upgrade everything within declared ranges
uv sync

Since an upgrade shows up precisely as a lock-file diff, three working rules follow:

  1. Don’t mix with feature changes: dependency upgrades get their own commit and their own PR. When something breaks, the unit to revert is unambiguous.
  2. Set a cadence: run --upgrade on a schedule like “first week of the month” and make the tests pass. Bots like Renovate and Dependabot are the automated version of this routine.
  3. Major upgrades beyond the range start at the declaration: moving to Django 6 begins with the decision to change <6 in pyproject.toml. The lock can never exceed the declaration.

pylock.toml: the standard connecting tools has arrived #

The long-standing weakness of lock files was that every tool had its own incompatible format (uv.lock, poetry.lock, …). PEP 751, accepted in 2025, defines the standard lock format pylock.toml, and that wall is coming down. As of 2026, uv can export and install from pylock.toml, and pip supports installing from it experimentally since 26.1.

Export in the standard format
uv export -o pylock.toml     # export in the standard format

The practical meaning is portability. If you develop with uv but your deployment pipeline only has pip, you can hand over the standard lock and cross the boundary without losing reproducibility. The era of tool choice being welded to lock format is ending. For now, treat uv.lock as the project’s source of truth and pylock.toml as the interchange copy.

Summary #

What we covered in this post:

  • pyproject.toml declares the allowed ranges; the lock file records the fully resolved dependency graph — exact versions, all transitive dependencies, hashes
  • uv.lock is platform-independent, so one file covers the whole team and CI, and unlike .venv it must be committed
  • Hash verification blocks swap-style supply-chain attacks at install time; complement it with pip-audit in CI for known vulnerabilities
  • uv sync --frozen is the CI default. When lock and declaration disagree, fail loudly instead of silently re-resolving
  • Make upgrades an independent PR on a regular cadence; major upgrades start by editing the declaration
  • PEP 751’s pylock.toml is the standard interchange format — lock with uv, install with pip

In the next post (#6 publishing) we reverse direction. So far we’ve been on the receiving end of other people’s packages; now we cover the maker’s workflow: building your code into a wheel and publishing it to PyPI.

X