Skip to content
← Blog

Technical explainer

Reproducible Python builds with hash-pinned requirements

3 min read

A reproducible Python build installs the same package versions, from the same sources, with content verified against pinned hashes — every time, on every machine. Python's tooling supports this end-to-end, but it requires a few specific commands and habits. This post walks through how to do it with pip-compile and pip --require-hashes, and the tradeoffs to expect.

What "reproducible" means here

Two installs of the same project at the same commit should produce identical files in site-packages. "Identical" means same versions, same wheels (including platform tags), and same content (verified against SHA-256 hashes recorded in the lockfile).

This is stronger than "deterministic resolution" — it requires both pinning the version selection and verifying the bytes received against the bytes you expected.

The two-step workflow

The clean shape is:

  1. requirements.in — your direct dependencies and version specifiers.
  2. requirements.txt — fully resolved, with exact versions and hashes for everything in the transitive closure. Generated by pip-compile.

Then in CI:

pip install --require-hashes -r requirements.txt

--require-hashes forces pip to refuse the install if any package's downloaded artifact does not match the recorded hash, or if any package in the requirements file lacks a hash.

Generating the lockfile

pip-compile (from pip-tools) is the standard tool. Install it once:

pip install pip-tools

Then generate requirements.txt from requirements.in:

pip-compile --generate-hashes requirements.in -o requirements.txt

The output is a flat file with every transitive dependency, each with --hash=sha256:... lines. It looks like:

fastapi==0.111.0 \
    --hash=sha256:a1b2c3...
starlette==0.37.2 \
    --hash=sha256:d4e5f6...

When you change requirements.in and re-run pip-compile, the lockfile updates. Commit both files.

Platform considerations

Python wheels are platform-specific. A wheel for cryptography on manylinux_2_17_x86_64 is a different file with a different hash from the wheel for macosx_11_0_arm64. pip-compile records hashes for the wheels available at compile time.

Two strategies exist:

Single canonical platform. Resolve once for the platform you deploy to (typically Linux x86_64) and ignore developer-machine variance. Developers may need --no-deps and a separate dev-deps file for tooling.

Multi-platform lock. Use pip-compile's --platform flag, or move to uv or Poetry, which can produce lockfiles with cross-platform hashes recorded for every supported platform. The result is one lockfile that installs cleanly on Linux, macOS, and Windows.

Modern tools (uv, Poetry 1.2+) handle multi-platform out of the box. If you start a new project today, prefer one of those over hand-rolling pip-compile invocations.

What --require-hashes enforces

With --require-hashes, pip:

  • Refuses to install if any line in requirements.txt lacks a hash.
  • Refuses to install if a downloaded artifact's hash does not match.
  • Refuses to install transitive dependencies that aren't explicitly listed with a hash, even if they are otherwise resolvable.

This is the pip equivalent of npm ci's integrity check. It removes a class of attacks: tampered tarballs in flight, registry-side overwrites of published versions, and accidental unpinned transitive upgrades.

Reproducibility beyond hashes

Hashes guarantee identical bytes. They do not guarantee identical behavior across environments — a wheel can produce different runtime results depending on linked C libraries on the host (think numpy and BLAS), Python interpreter version, or environment variables. For full behavioral reproducibility, pin those too: container images for the runtime, locked Python version, locked OS image.

The realistic goal for most teams is "byte-identical wheels in site-packages, same Python version, same base container." That's enough to make build outputs reproducible.

Common pitfalls

pip install -r requirements.txt (no hashes). Permissive: pip will install whatever the registry serves. Use --require-hashes.

Skipping pip-compile and writing requirements.txt by hand. You miss transitive dependencies. The first pip install finds them and resolves them at install time, undoing pinning.

Editing requirements.txt manually after generation. If you must, re-run pip-compile to regenerate hashes. A hand-edited line without the matching hash will fail under --require-hashes.

Skipping the dev-deps file. Developer tools (linters, formatters, test runners) are dependencies too. Use a separate requirements-dev.in resolved alongside or independently.

Takeaway

Reproducible Python builds are a habit, not a tool. Use pip-compile --generate-hashes to produce a fully-resolved lockfile with content hashes; use pip install --require-hashes to install. For new projects, consider uv or Poetry for cleaner cross-platform lockfile handling. The result is that pip install becomes a verifiable contract instead of a "best effort" against the live registry.