Repeatable & Secure Installs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the Python Reference bibliography.

A repeatable install gives the same files every time; a secure install also proves that they are the files you meant. pip does both with pinned versions and hash-checking mode. This page was written against pip 26.2.2 and run on CPython 3.14.6 on 2026-10-22, with the birdtally wheelhouse built on Package Indexes & Finding Packages and a deliberately tampered copy of the birdtally wheel. The broader threats are discussed on Supply-Chain Security.

Why pinning is not enough

Pinning every package with == (a pip freeze, a lock file) fixes the versions. It does not fix the files: an index can serve a different file under the same name (a compromised mirror, a man-in-the-middle on a misconfigured network, a new wheel added to an old release), and an sdist may build differently on another machine. Hashes close that gap: they name the exact bytes.

Hash-checking mode

A requirement line may carry one or more --hash=<algorithm>:<digest> options. As soon as one line in the run has a hash, pip switches to hash-checking mode for the whole run (and --require-hashes, PIP_REQUIRE_HASHES, forces it on). In this mode:

  • every requirement must have a hash, because a partially hashed file lets bad code in through an unhashed line;

  • every dependency must be listed and hashed as well, not only what you asked for;

  • every requirement must be pinned, with ==, a URL or a path, so that a new release cannot cause a surprise mismatch;

  • a downloaded or cached file whose hash is not in the list is refused.

pip hash computes the hash of a local file (sha256 by default, -a/--algorithm sha384|sha512):

python -m pip hash wheelhouse/birdtally-0.1.0-py3-none-any.whl
wheelhouse/birdtally-0.1.0-py3-none-any.whl:
--hash=sha256:35a5069350968616bed7f40135c5594911438241543d3b4a03fa389984a56b5a

A complete hashed lock for the birdtally wheelhouse (one hash per package, because only wheels are used):

birdtally==0.1.0 \
    --hash=sha256:35a5069350968616bed7f40135c5594911438241543d3b4a03fa389984a56b5a
markdown-it-py==4.2.0 \
    --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
mdurl==0.1.2 \
    --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8
pygments==2.21.0 \
    --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9
rich==15.0.0 \
    --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb
python -m pip install --no-index --find-links wheelhouse -r requirements.lock
…
Successfully installed birdtally-0.1.0 markdown-it-py-4.2.0 mdurl-0.1.2 pygments-2.21.0 rich-15.0.0

A tampered file: HashMismatch

Someone with write access to the wheelhouse unpacks the birdtally wheel, appends a line that runs a shell command to birdtally/__init__.py, and zips it again under the same name. The next install refuses it before anything is unpacked:

Looking in links: wheelhouse
Processing ./wheelhouse/birdtally-0.1.0-py3-none-any.whl (from -r requirements.lock (line 1))
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    birdtally==0.1.0 from file://…/wheelhouse/birdtally-0.1.0-py3-none-any.whl (from -r requirements.lock (line 1)):
        Expected sha256 35a5069350968616bed7f40135c5594911438241543d3b4a03fa389984a56b5a
             Got        1e8a01452ce089cf563f6ebbbadd78d00046448fc98424921bbba5ac2e91379e

The exit code is 1 and nothing is installed. Without the hashes, the same command would have installed the modified package silently, because its name and version are unchanged.

A missing hash: HashMissing

A lock that hashes birdtally but not rich:

birdtally==0.1.0 \
    --hash=sha256:35a5069350968616bed7f40135c5594911438241543d3b4a03fa389984a56b5a
rich==15.0.0
Collecting rich==15.0.0 (from -r missing.lock (line 3))
  Using cached rich-15.0.0-py3-none-any.whl (310 kB)
ERROR: Hashes are required in --require-hashes mode, but they are missing from some requirements. Here is a list of those requirements along with the hashes their downloaded archives actually had. Add lines like these to your requirements files to prevent tampering. (If you did not enable --require-hashes manually, note that it turns on automatically when any package has a hash.)
    rich==15.0.0 --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb

The message prints the hash of what it downloaded, which is a convenient way to bootstrap a hash list with --require-hashes, if you trust the download at that moment. It fetches only the preferred file of each package, so add the other files (an sdist, wheels for other platforms) with pip hash yourself.

An unpinned dependency

If a hashed requirement brings in a dependency that is not listed, the error names the specifier it came from:

Collecting rich>=13 (from birdtally[pretty])
ERROR: In --require-hashes mode, all requirements must have their versions pinned with ==. These do not:
    rich>=13 from https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl (from birdtally[pretty])

--no-require-hashes (pip 26.2)

The all-or-nothing rule made it impossible to combine a hashed lock with, say, an editable local project or a VCS URL, which cannot have a hash. --no-require-hashes (PIP_NO_REQUIRE_HASHES, also allowed in requirements files) stops the automatic switch: hashes are then checked only where they are given. The partial lock above then installs:

python -m pip install --dry-run --ignore-installed --no-require-hashes --find-links wheelhouse -r partial.lock "birdtally[pretty]"
Would install Pygments-2.21.0 birdtally-0.1.0 markdown-it-py-4.2.0 mdurl-0.1.2 rich-15.0.0

Use it deliberately: everything without a hash is unprotected again.

Algorithms, multiple hashes and index hashes

  • sha256 is recommended; sha384 and sha512 are accepted. md5, sha1 and sha224 are refused for --hash "to avoid giving a false sense of security":

    ERROR: Invalid requirement: tomli-w==1.2.0 --hash=md5:0123456789abcdef0123456789abcdef
    __main__.py: error: Allowed hash algorithms for --hash are sha256, sha384, sha512.
  • Several --hash options per line are normal: one for each file pip might legitimately pick (the sdist, and wheels for each platform and Python). Any one of them matching is enough.

  • Hashes from the index (the #sha256= fragment in a simple API link) are always checked against download corruption, but they come from the same server as the file, so they do not satisfy hash-checking mode.

  • The wheel cache is used in hash mode since pip 23.1: a wheel built from an sdist is accepted if the sdist it came from matches your hash, which pip records in an origin.json next to the cached wheel.

More layers

  • --only-binary :all: forbids sdists, so no build code of a dependency runs during the install.

  • Wheelhouse bundles. Build the wheels once (pip wheel -w wheelhouse -r requirements.lock), ship the directory, and install with --no-index --find-links wheelhouse --require-hashes: no network, no builds, no unexpected files. The pip documentation calls these installation bundles.

  • Install your own project with --no-deps. After the hashed dependencies, pip install --no-deps . (or --no-deps -e .). The old python setup.py install/develop and easy_install would "happily go out and download, unchecked, anything you missed".

  • Generate the hashed file with a tool: poetry export -f requirements.txt (hashes by default), pip lock (hashes in pylock.toml), or the third-party pip-compile --generate-hashes from pip-tools.

Hash checking also works with pip download and pip wheel.

pip and Poetry

Poetry always works in the equivalent of hash-checking mode: poetry.lock records the hash of every file of every locked package, and poetry install refuses a file whose hash does not match. Since Poetry 2.4.2 it also refuses to install an artefact that is not listed in the lock when the source provides no hash for it.