Pin Django Dependencies with pip-tools and Hashes

Make Django installs reproducible and tamper-resistant with pip-tools: requirements.in, compiled pins with hashes, separate dev requirements, pip install --require-hashes, and safe upgrades.

Problem statement

A requirements.txt containing Django>=5.0 and celery installs whatever the newest versions are on the day of the deploy, together with whatever versions of their dependencies pip resolves. Two servers deployed a week apart can run different code, a transitive dependency can break production without any change in your repository, and nothing verifies that the downloaded files are the ones you tested.

pip-tools separates what you want (requirements.in) from exactly what gets installed (requirements.txt with every package pinned, including transitive ones), and can record a cryptographic hash for every file. pip then refuses to install anything that does not match. This guide sets that up for a Django project.

pip-tools dependency flowrequirements.indirect dependenciespip-compileresolve and hashrequirements.txtevery pin + hashpip require-hashesexact installProduction installs only the pinned versions whose hashes match the compiled file.
  1. Put direct dependencies with loose constraints in requirements.in.
  2. Compile with pip-compile --generate-hashes -o requirements.txt requirements.in using the same Python version as production.
  3. Put development tools in requirements-dev.in constrained by -c requirements.txt.
  4. Install in production with pip install --require-hashes --no-deps -r requirements.txt into a fresh virtual environment.
  5. Upgrade deliberately with pip-compile --upgrade-package <name> and review the diff.
  6. Commit both .in and .txt files; CI checks that the compiled file is up to date.

Step-by-step solution

1. Declare direct dependencies

# requirements.in
django>=5.2,<6.0
gunicorn
psycopg[binary]
whitenoise
celery[redis]
sentry-sdk[django]

Only packages your code imports or runs directly belong here, with ranges that express compatibility (for example staying on a Django LTS series).

2. Compile with hashes

python -m pip install pip-tools
pip-compile --generate-hashes --strip-extras -o requirements.txt requirements.in
what pip-compile writesdjango==5.2.6\--hash=sha256:… \# via -r requirements.insqlparse==0.5.3 \#via django1231Exact version, even for sub-dependencies.2pip refuses any file that does not match.3Why it is here: pulled in by Django.requirements.in lists what you chose; the .txt lists what you getdjango-deployment.com
Commit both files; review the .txt diff on every upgrade.

The output pins everything and explains where each package comes from:

django==5.2.6 \
    --hash=sha256:... \
    --hash=sha256:...
    # via -r requirements.in
sqlparse==0.5.3 \
    --hash=sha256:... \
    --hash=sha256:...
    # via django

Compile with the same Python version (and ideally the same operating system family) as production. Environment markers (for example a backport needed only on older Python versions) are evaluated at compile time, so a file compiled on Python 3.13 can miss packages that Python 3.12 needs.

Each pinned version lists hashes for all its published files (wheels for every platform plus the source distribution), so one compiled file installs on Linux servers and macOS laptops alike.

3. Separate development requirements

# requirements-dev.in
-c requirements.txt
pytest-django
django-debug-toolbar
ruff
pip-compile --generate-hashes -o requirements-dev.txt requirements-dev.in

-c requirements.txt constrains shared packages to the production pins, so tests run against the same Django and psycopg versions as production. Production installs only requirements.txt.

4. Install in production

python3 -m venv /srv/myapp/releases/2026-09-25/.venv
/srv/myapp/releases/2026-09-25/.venv/bin/pip install --require-hashes --no-deps -r requirements.txt
install exactly the lock filepip install -r requirements.txt✓hash checking on automatically✓adds and upgrades packages✕never removes extraspip-sync requirements.txt✓installs what is listed✓removes what is not✓environment matches the fileA fresh virtualenv per release gets the same result with plain pipdjango-deployment.com
Stray packages left behind can mask a missing requirement.
  • --require-hashes makes pip reject any package without a matching hash. Hash-checking mode is also enabled automatically when any requirement has a hash, but stating it explicitly makes the intent clear.
  • --no-deps prevents pip from pulling in anything not listed; since the compiled file is complete, a missing dependency points to a compile problem rather than being silently installed unpinned.
  • A fresh virtual environment per release avoids packages left over from earlier versions. In an existing environment, pip-sync requirements.txt removes packages that are no longer listed.

In Docker:

COPY requirements.txt .
RUN pip install --no-cache-dir --require-hashes --no-deps -r requirements.txt

5. Check the compiled file in CI

pip-compile --generate-hashes --strip-extras -o requirements.txt requirements.in --quiet
git diff --exit-code requirements.txt

If someone edits requirements.in without recompiling, the diff is non-empty and CI fails. Pin the pip-tools version in CI so its output format does not change unexpectedly between runs; otherwise this check fails on formatting alone.

Also audit the pins for known vulnerabilities:

pip install pip-audit
pip-audit -r requirements.txt

6. Upgrade deliberately

pip-compile --generate-hashes --upgrade-package django -o requirements.txt requirements.in
pip-compile --generate-hashes --upgrade -o requirements.txt requirements.in   # everything
git diff requirements.txt
one upgrade, reviewed1pip-compile --upgrade-package djangoone package at a time2Read the diffwhat else moved?3Run the testswith the new lock file4Deploy to stagingthen production5Commit both files.in and .txt togetherA blanket --upgrade makes failures hard to attributedjango-deployment.com
Dependabot and Renovate can open these one-package upgrades for you.

--upgrade-package upgrades one package (and whatever it needs) while keeping other pins. Review the diff, run the tests, and deploy through staging. Dependency update bots can open these pull requests for you.

Verification check:

.venv/bin/pip check
.venv/bin/pip freeze | sort > /tmp/installed.txt
grep -E "^[a-zA-Z0-9_.-]+==" requirements.txt | sed 's/ .*//' | sort > /tmp/pinned.txt
diff <(cut -d= -f1 /tmp/installed.txt | tr 'A-Z_' 'a-z-') <(cut -d= -f1 /tmp/pinned.txt | tr 'A-Z_' 'a-z-')

pip check should report no broken requirements, and installed packages should match the pinned list.

Explanation

Reproducibility has two parts: the same versions and the same files. Pinning every package, including transitive dependencies, fixes the versions; hashes fix the files, so a compromised mirror, a re-uploaded package, or a man-in-the-middle cannot substitute different code. pip-tools produces both from a short, human-maintained input file, and pip enforces them at install time.

Edge cases / notes

Packages from Git or private indexes

Hash-checking mode requires hashes for every requirement, which does not work for unpinned VCS URLs. Publish internal packages to a private index or build them as wheels in CI, then pin them like any other package.

Hash mismatches after an index change

If a mirror serves different files than PyPI, installs fail with hash errors. That is the protection working. Recompile against the index production uses, or fix the mirror.

uv as a faster alternative

uv pip compile --generate-hashes produces compatible files much faster, and uv pip sync installs them. The workflow in this guide carries over unchanged.

When to automate this

Let CI enforce the compiled file and the vulnerability audit on every pull request, and let an update bot propose upgrades weekly. Humans then only review diffs and test results.

Next steps

FAQ

What is the difference between requirements.in and requirements.txt?

requirements.in lists your direct dependencies with version ranges. requirements.txt is generated from it and pins every package, including transitive dependencies, optionally with hashes.

Why use --generate-hashes?

Hashes let pip verify that each downloaded file is exactly the one you compiled against, which protects against tampered or substituted packages.

Why does pip say "Hashes are required in --require-hashes mode"?

A requirement has no hash, often one added by hand or a dependency missing from the compiled file. Recompile instead of editing requirements.txt manually.

Should I compile requirements on my laptop or in CI?

Either, as long as the Python version matches production. Many teams compile in CI or in a container with the production Python version.

Keep going