Reproducible Installs
Pin what you ship, range what you develop against, and hash-lock the install so Tuesday's build matches Monday's.
pip install --require-hashes -r requirements.txtTwo different questions get collapsed into one file. "What does this project work with?" is a set of ranges, and it belongs in pyproject.toml as a statement of intent. "What exactly did we ship?" is a fully resolved list, every transitive dependency included, with hashes, and it belongs in version control as a record.
Why Tuesday differs from Monday
boto3>=1.34 resolves to the newest matching release at the moment of the build. The same commit built on two days installs different bytes, so a failure that reproduces in CI but not on your laptop can have nothing at all to do with your code.
Ranges, and the shorthand
PEP 440 defines ~=1.4.5 as shorthand for >=1.4.5, ==1.4.*, and ~=1.4 for >=1.4, ==1.*. They are the same constraint written two ways, and you need to recognise both. ==1.4.5 pins exactly.
Locking
Compile the ranges into a pinned set and install that with pip install --require-hashes -r requirements.txt; a digest mismatch then fails the build instead of quietly installing a republished artefact. --require-hashes is implied as soon as any requirement in the file carries a --hash option, and it also forces every dependency to be pinned, which is the discipline you wanted. PEP 751 (final since March 2025) standardises the filename pylock.toml and a lock-version key for this; uv and PDM write it, and pip lock has produced it experimentally since pip 25.1.
Keep test and lint tools in a separate group so the runtime image does not carry your test runner.
Example
When to use it
- Reproduce last month's release build byte for byte while bisecting a regression.
- Make a dependency bump appear as a reviewable diff instead of a surprise in production.
- Stop a compromised or republished artefact from installing by verifying hashes.
- Keep pytest, mypy and ruff out of the runtime image while keeping them one command away locally.
More examples
The compiled, hashed requirements file
The input file holds three lines of intent; this file holds every package that will actually be installed, each with the digests pip must verify. Regenerate it as a reviewable diff, so a dependency bump shows up in a pull request rather than in production.
# requirements.txt
# Generated by: pip-compile --generate-hashes --output-file=requirements.txt requirements.in
# Edit requirements.in and recompile; do not edit this file by hand.
boto3==1.35.49 \
--hash=sha256:<64 hex characters written by the tool> \
--hash=sha256:<one per artefact: sdist and each wheel>
# via -r requirements.in
botocore==1.35.49 \
--hash=sha256:<...>
# via boto3 <- transitive, and pinned too: this is the point
jmespath==1.0.1 \
--hash=sha256:<...>
# via boto3, botocore
# Install with:
# pip install --require-hashes -r requirements.txt
# A republished artefact, a compromised mirror or a stale cache now fails the
# build instead of installing silently.pyproject.toml: ranges plus a tools group
Ranges here, pins in the lockfile: the two files answer different questions and both are checked in. Keeping the tools in a group rather than an extra means they are not part of your published metadata and cannot be pulled in by someone installing your package.
[project]
name = "billing"
version = "2.1.0"
requires-python = ">=3.12"
dependencies = [
"boto3>=1.34,<2", # a range: the versions the code is known to work with
"structlog~=24.1", # identical to >=24.1, ==24.*
"httpx>=0.27,<0.29",
]
# PEP 735 dependency groups: development tools that must never reach the
# runtime image. pip installs them with: pip install --group dev
[dependency-groups]
dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6", "pip-tools>=7"]
[project.optional-dependencies]
# Extras are different: they are published and installable by consumers,
# as in pip install billing[postgres]
postgres = ["psycopg[binary]>=3.2"]Installing the lock in a Dockerfile
These three flags turn the install from a resolution into a verification. The build either reproduces the exact set that was tested, or it fails - there is no third outcome where it quietly installs a newer transitive dependency.
COPY requirements.txt .
# --require-hashes: verify every digest, and refuse any unpinned requirement.
# --no-deps: the lock is already complete, so pip must not resolve anything
# extra - if a package is missing, that is a bug in the lock, and
# you want it to fail here rather than drift in silence.
# --no-cache-dir: no pip cache in the layer.
RUN pip install --require-hashes --no-deps --no-cache-dir -r requirements.txt
# The dev group is deliberately absent: no pytest, no mypy, no ruff in the
# image you deploy. CI installs them in its own step.
Discussion