Safeguard
Open Source Security

Pinning Python dependencies: requirements.txt vs Poetry a...

A practical guide to python dependency pinning with requirements.txt, pip-tools, and Poetry — locked, hash-verified builds instead of moving-target dependencies.

Aman Khan
AppSec Engineer
8 min read

Unpinned Python dependencies are one of the quietest ways a supply chain attack slips into production. A bare requests in requirements.txt looks harmless until a maintainer's account is compromised, a typosquat lands in PyPI, or a routine pip install silently pulls a newer, subtly malicious release. Python dependency pinning closes that gap by locking every package — direct and transitive — to an exact, verified version so builds are reproducible and auditable instead of a moving target.

This guide walks through three approaches to python dependency pinning — a hand-maintained requirements.txt, pip-tools, and Poetry — and shows how to pick the right one, generate a real lockfile, verify it with hashes, and keep it current without reopening the door to unvetted code. By the end you'll have a locked, hash-verified dependency set for at least one of these tools, plus a repeatable process for updating it safely.

Step 1: Understand Your Python Dependency Pinning Options

Before touching a config file, decide what "pinned" needs to mean for your project. There are three common tiers:

  • Loose pinning (requests>=2.0) — flexible, but non-reproducible; a new release can land in your build without review.
  • Exact pinning (requests==2.31.0) — reproducible for the packages you list, but says nothing about transitive dependencies pulled in underneath them.
  • Full lockfile pinning — every direct and transitive dependency is pinned to an exact version and, ideally, a cryptographic hash of the artifact. This is what pip-tools and Poetry produce, and it's the standard you should hold production services to.

A plain requirements.txt can approximate tier two but not tier three on its own. That's the core reason teams graduate to pip-tools or Poetry once a project has more than a handful of dependencies.

Step 2: Pin Exact Versions with requirements.txt

If you're not ready to adopt a new tool, start by tightening requirements.txt itself. This is the baseline every Python project should meet:

# requirements.in — human-edited direct dependencies
flask==3.0.3
sqlalchemy==2.0.31
requests==2.32.3

Freeze the actual resolved environment into a second file so transitive packages are captured too:

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.in
pip freeze > requirements.txt

Key requirements.txt best practices:

  • Never commit a requirements.txt generated with loose specifiers (>=, ~=) for anything deployed to production — pin exact versions with ==.
  • Keep a separate requirements.in (or requirements-dev.txt) for direct, human-intended dependencies, and treat the frozen requirements.txt as a generated artifact, not something you hand-edit.
  • Add --require-hashes support by generating hashes with pip hash or, better, let pip-tools do it for you (next step) — a version pin alone doesn't stop a compromised index from serving different bytes under the same version string.
  • Regenerate the freeze file in a clean virtual environment every time, so stale or locally-installed packages don't leak into the lock.

This gets you reproducible installs, but it's manual, easy to drift, and doesn't give you hash verification without extra tooling — which is why most teams move to pip-tools or Poetry once the dependency graph grows.

Step 3: Generate a Deterministic Lockfile with pip-tools

pip-tools keeps the simple requirements.in / requirements.txt split you already know, but automates resolution and hash generation.

pip install pip-tools

Declare direct dependencies in requirements.in:

flask
sqlalchemy
requests

Compile a fully pinned, hashed pip-tools lockfile:

pip-compile --generate-hashes --output-file=requirements.txt requirements.in

The resulting file pins every transitive package and includes a hash per artifact:

flask==3.0.3 \
    --hash=sha256:34e815dfaa43439d4a83f2b3d6c30a4e2091faae1e19d33ba9c30f8dfc09a34

Install with hash enforcement so pip refuses anything that doesn't match:

pip-sync requirements.txt
# or
pip install --require-hashes -r requirements.txt

To update a single package deliberately (rather than re-resolving everything and risking surprise upgrades):

pip-compile --generate-hashes --upgrade-package flask requirements.in

Commit both requirements.in and the compiled requirements.txt — the .in file documents intent, the pip-tools lockfile documents exactly what shipped.

Step 4: Manage Dependencies with Poetry and poetry.lock

Poetry combines dependency declaration, resolution, and packaging into one tool, with pyproject.toml as the source of truth and poetry.lock as the resolved, hashed lockfile.

pip install poetry
poetry init
poetry add flask sqlalchemy requests

pyproject.toml records the constraints you asked for:

[tool.poetry.dependencies]
python = "^3.11"
flask = "^3.0.3"
sqlalchemy = "^2.0.31"
requests = "^2.32.3"

poetry.lock records the exact resolved graph with hashes — this file, not pyproject.toml, is what guarantees reproducibility:

poetry lock
poetry install --sync

poetry install --sync also removes packages that are no longer in the lockfile, preventing orphaned dependencies from lingering in an environment. For poetry.lock security specifically, treat the lockfile with the same rigor as source code:

  • Commit poetry.lock to version control for applications (libraries are the exception — they typically ship loose ranges and let consumers lock).
  • Never hand-edit poetry.lock; always regenerate it via poetry lock so hashes stay consistent with the resolved versions.
  • Run poetry check in CI to catch a pyproject.toml/poetry.lock drift before it reaches a build.
  • Use poetry export --format=requirements.txt --output=requirements.txt if downstream tooling (Docker base images, non-Poetry deploy pipelines) still expects a flat requirements file — export it, don't maintain it by hand alongside the lockfile.

Step 5: Pin Transitive Dependencies and Hashes, Not Just Top-Level Packages

Whichever tool you use, verify the lockfile actually covers the full graph, not just what you typed:

# pip-tools
pip-compile --generate-hashes requirements.in
grep -c "hash=" requirements.txt

# Poetry
poetry show --tree

poetry show --tree and pip list (run inside the locked environment) should both show far more packages than your direct declarations — that gap is your transitive dependency surface, and it's exactly where unpinned risk hides. A dependency you never wrote a line for can still introduce a malicious release if it isn't locked to a specific, hash-verified artifact.

Step 6: Automate Lockfile Updates and Vulnerability Scanning

Pinning isn't a one-time task — a stale lockfile eventually means you're deliberately running known-vulnerable versions. Build the update cycle into CI:

# example CI job, weekly
- run: pip-compile --generate-hashes --upgrade requirements.in
# or
- run: poetry update

Pair every update with a scan before it merges:

pip install pip-audit
pip-audit -r requirements.txt
# or for Poetry
poetry export -f requirements.txt --output=requirements.txt
pip-audit -r requirements.txt

Open the update as a pull request rather than auto-merging, so a human reviews the diff of the lockfile — not just the version bump, but any new transitive packages that appeared.

Verification and Troubleshooting

"pip install --require-hashes" fails with "not all requirements have hashes." Every entry in the file needs a hash once you enable enforcement, including transitive packages. Regenerate with pip-compile --generate-hashes rather than editing the file manually.

Poetry resolution hangs or fails with a conflict. Run poetry lock --no-update to see if the existing lock is still valid, or poetry add <package> --dry-run to isolate which constraint is unsatisfiable. Loosen an overly strict caret constraint in pyproject.toml rather than deleting the lockfile.

Two developers get different pip freeze output from the "same" requirements.txt. This means requirements.txt was hand-maintained with loose specifiers somewhere in the chain. Confirm every line uses ==, and check for a rogue pip install outside the lockfile that polluted a shared environment.

poetry.lock is flagged as out of date in CI. Run poetry lock --check locally, then poetry lock to regenerate, and commit the updated file alongside the pyproject.toml change that caused the drift.

CI installs succeed but production behaves differently. Confirm the exact same lockfile (byte-for-byte, checked via sha256sum requirements.txt or poetry.lock) is used to build both environments. Divergence usually means a Docker layer cache or a base image is installing from a different, unlocked source.

How Safeguard Helps

Locking your dependencies is necessary but not sufficient — a lockfile only protects you if what's inside it is actually safe, and that changes over time as new CVEs land against packages you pinned months ago. Safeguard continuously monitors your requirements.txt, pip-tools lockfiles, and poetry.lock files across every repository, flagging vulnerable or malicious packages the moment they're disclosed rather than waiting for your next scheduled audit. It verifies that lockfiles match their declared manifests, catches drift between pyproject.toml and poetry.lock before it reaches production, and surfaces risky transitive dependencies that hand review typically misses. Instead of trusting that pinning was done correctly once, Safeguard gives you ongoing, automated assurance that your Python supply chain stays locked down as it evolves — turning dependency pinning from a one-time hygiene task into a continuously enforced security control.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.