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.
- Put direct dependencies with loose constraints in
requirements.in. - Compile with
pip-compile --generate-hashes -o requirements.txt requirements.inusing the same Python version as production. - Put development tools in
requirements-dev.inconstrained by-c requirements.txt. - Install in production with
pip install --require-hashes --no-deps -r requirements.txtinto a fresh virtual environment. - Upgrade deliberately with
pip-compile --upgrade-package <name>and review the diff. - Commit both
.inand.txtfiles; 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
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
--require-hashesmakes 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-depsprevents 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.txtremoves 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
--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
- Use a lock-file-first tool with Deploy Django with uv for Reproducible Installs.
- Keep pins fresh with Automate Django Dependency and Security Updates.
- Review supply-chain controls with the Django Dependency Security Checklist.
- If a pinned package will not install on the server, see Fix psycopg Installation Errors on Django Servers.
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.