Pin dependency versions
Pin dependency versions with pip-tools — Secure development. Learn hands-on steps, troubleshooting, and what to study next.
Focus: pin dependency versions with pip-tools
You've written the code, tested it locally, and pushed it to production. Then one Tuesday, a transitive dependency releases a patch that breaks your app in a subtle way — or worse, a compromised version of a package you depend on silently lands in your environment. Without pinned versions, your production build is a moving target, and that's a security and stability risk you can't afford. This lesson shows you how to pin dependency versions with pip-tools, the industry-standard workflow for locking down every single package in your Python project.
The problem this lesson solves
When you install Python packages with pip install, it resolves the latest versions that satisfy your requirements. A file like requirements.txt with Django or requests means "any version," so today's deployment and next week's could pull entirely different code. This leads to two major problems:
- Security vulnerabilities: You don't know which exact version is running, so you can't audit it against CVEs or reproduce a vulnerability report.
- Non-reproducible builds: A teammate or CI environment installs different versions, causing "works on my machine" bugs and hard-to-trace production failures.
The fix is simple in concept: specify exact versions for every installed package — including the dependencies of your dependencies. But managing that manually is tedious and error-prone. That's where pip-tools steps in, automating the entire process.
Core concept / mental model
Think of your Python project as a tree. At the top are the packages you directly depend on, like flask or psycopg2. Below them are their dependencies, then their dependencies' dependencies, and so on — the transitive dependencies. Pinning only the top level leaves the rest of the tree vulnerable to change. To lock down the entire tree, you need a precise list of every leaf and branch.
requirements.in: Your declared, high-level dependencies (e.g.,flask,psycopg2). This file is human-written and gives pip-tools its starting point.requirements.txt: The generated, fully pinned output. Every package is listed with an exact version, and it's the file you install in production.- pip-tools: A tool that reads
requirements.in, resolves the dependency graph, and generatesrequirements.txt. It's like apackage-lock.jsonfor Python, but with a more transparent, manual control flow.
In short, pip-tools separates "what I want" from "exactly what I get," and it ensures the latter is stable and auditable.
How it works step by step
- Create a virtual environment for your project to keep dependencies isolated.
- Install pip-tools into that environment:
bash pip install pip-tools - Write
requirements.inlisting your direct dependencies. Use version constraints like>=or~=to allow some flexibility, but avoid bare names unless you want the latest at compile time. - Run
pip-compileto generaterequirements.txt. This command readsrequirements.in, resolves all transitive dependencies based on your Python version and platform, and writes exact versions. - Install from the pinned file in your runtime environment:
pip install -r requirements.txt. This is the command you use in Dockerfiles, CI pipelines, and production servers. - Update dependencies deliberately when you want to pull in new versions — never just run
pip installand hope.
A note on pip-compile output
By default, pip-compile writes to requirements.txt. The generated file includes a header comment explaining how it was produced, plus each package with a == version pin. It also includes hashes if you pass --generate-hashes, which we'll cover later.
Hands-on walkthrough
Let's put this into practice with a small Flask project.
Step 1: Set up and install pip-tools
mkdir mysecureapp && cd mysecureapp
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install pip-tools
Step 2: Create requirements.in
# mysecureapp/requirements.in
flask
requests
psycopg2-binary
These are your direct dependencies. Notice: no version numbers. You're telling pip-tools, "give me something good," and it will decide the exact versions.
Step 3: Compile the lock file
pip-compile requirements.in
This creates requirements.txt. Here's a snippet of what it might contain:
#
# This file is autogenerated by pip-compile with Python 3.11
# from the following requirements:
#
# requirements.in
#
blinker==1.7.0
# via flask
click==8.1.7
# via flask
flask==3.0.2
# via -r requirements.in
markupsafe==2.1.5
# via jinja2
psycopg2-binary==2.9.9
# via -r requirements.in
requests==2.31.0
# via -r requirements.in
urllib3==2.2.1
# via requests
werkzeug==3.0.1
# via flask
Notice how requests pulls in urllib3, and Flask brings Werkzeug, click, and more. These are transitive dependencies, and now they're locked.
Step 4: Install the pinned versions
pip install -r requirements.txt
Now your environment matches the lock file exactly. To confirm, run pip freeze — you'll see the same versions.
Step 5: Update a dependency safely
When you need to upgrade, say Flask, you update the version in requirements.in and re-run pip-compile:
# requirements.in
flask==3.1.0
pip-compile requirements.in
pip install -r requirements.txt
Now requirements.txt reflects an exact upgrade, and you can test it before deploying.
Pro tip: Always run
pip-compilein the same Python version your production uses. The resolution can differ between Python releases — a package might require a newer interpreter.
Compare options / when to choose what
pip-tools isn't the only way to manage Python dependencies. Here's a quick guide:
| Tool | What it does | Best for | Trade-off |
|---|---|---|---|
| pip-tools | Compiles requirements.in to a pinned requirements.txt |
Teams wanting explicit, reviewable lock files | Requires an extra tool and manual compilation step |
| Poetry | Full dependency manager with its own lock file | New projects needing packaging and publishing | Heavier lock file format, steeper learning curve |
| pip freeze | Dumps current environment's exact versions | Quick, one-off environment snapshots | No resolution logic; freezes everything, including dev-only packages |
| Pipenv | Similar to Poetry but simpler | Simple apps with both production and dev dependencies | Slower resolution, less active maintenance |
When to choose pip-tools: If you already use requirements.txt and want minimal friction with a transparent, diff-friendly lock file. It works well with Docker and CI, and it integrates cleanly with pip-sync (which we'll see in a moment).
When to choose something else: If you're starting a new project and want a more modern, all-in-one solution, Poetry might be a better fit. If you need deterministic, repeatable installs across all environments, pip-tools is your friend.
Troubleshooting & edge cases
pip-compile takes too long
The resolver can be slow for large dependency graphs. Use the --verbose flag to see what's happening. You can also cache the resolution with a constraints.txt file that pins known-good versions, but that defeats the purpose — better to let it resolve.
"No matching distribution found"
If you see an error like ERROR: No matching distribution found for somepkg==1.0, the pinned version isn't available for your Python version or platform. Check if the package has a wheel for your environment, and consider using a version range instead of an exact pin.
Inconsistent lock files between platforms
pip-compile resolves based on your OS and Python version. A lock generated on Windows may differ from Linux for packages with architecture-specific code (e.g., psycopg2). Solution: generate the lock on the same platform as production, typically Linux in a Docker container.
Accidentally pinned a vulnerable version
Pinning is about reproducibility, not security guarantees. When a CVE is announced for a package you've pinned, run pip-compile --upgrade-package insecure-pkg to update just that one and commit the diff.
Managing multiple environments (dev vs prod)
Use separate input files:
# requirements-dev.in
-r requirements.in
pytest
black
Then compile both:
pip-compile requirements-dev.in
This generates requirements-dev.txt that includes both production and development dependencies.
Using hashes for supply-chain security
pip-tools can add SHA-256 hashes to your lock file with the --generate-hashes flag:
pip-compile --generate-hashes requirements.in
Now pip install will verify the integrity of each package against known hashes, protecting against tampered wheels. This is a strong addition to your secure development toolkit.
What you learned & what's next
You now understand the problem of non-reproducible dependencies and the mental model of separating direct vs. transitive dependencies. You can apply pip-compile to generate a fully pinned requirements.txt, update it safely, and use advanced features like hashes. You've also seen how to troubleshoot common resolution issues and maintain dev/prod splits.
Key takeaways:
- Pip-tools turns a vague
requirements.txtinto a precise, auditable lock file. - Use
requirements.infor your declared dependencies andrequirements.txtfor the compiled, pinned output. - Compile on the same platform and Python version as production for consistency.
- Use
--generate-hashesto guard against supply-chain attacks. - Promote any change to your lock file through code review, just like your source code.
Next step: In the next lesson, you'll learn how to use pip-audit to scan your pinned dependencies for known vulnerabilities — turning a reproducible environment into a verifiably secure one. You'll see how pip-tools and pip-audit complement each other in a hardened CI pipeline.
Pro tip: Combine
pip-compile --generate-hashesandpip auditin your CI to catch both integrity and known vulnerabilities before they reach production.
Practice recap
Create a tiny Flask project, then run pip-compile to generate a requirements.txt. Study the transitive dependencies that appear, install them, and then upgrade one package using the --upgrade-package flag. If you're confident, add hashes with --generate-hashes and try pip-sync to see how it installs the exact locked versions.
Common mistakes
- Pinning only top-level dependencies and ignoring transitive ones — leaves the entire subtree vulnerable to change.
- Generating the lock file on a developer machine and deploying on a different platform, causing resolution conflicts.
- Running
pip installdirectly on a project without a lock file, bypassing the pinning process entirely. - Using
pip freezeoutput as requirements.txt — this includes packaging tools likepiporsetuptoolsyou never intended to lock.
Variations
- Poetry is a full dependency manager with a built-in lock file and can replace pip-tools if you're starting fresh.
- Use
pipenvfor a simpler alternative with aPipefile.lockand automatic environment management. - Hand-write exact version pins in
requirements.txtif you're working on a tiny, single-package project and don't need transitive resolution.
Real-world use cases
- A web application uses pip-tools to lock flask and its dependencies, ensuring every server in the cluster runs identical code.
- A data science project pins pandas and numpy to guarantee model reproducibility across teammates and CI machines.
- A security-conscious organisation generates hashed lock files to protect against compromised packages in a multi-region deployment.
Key takeaways
- Pinning dependency versions prevents non-reproducible builds and known supply-chain risks.
- pip-tools compiles
requirements.ininto a fully pinnedrequirements.txtincluding transitive dependencies. - Always compile lock files on the same platform and Python version as production.
- Use
--generate-hashesto add integrity verification to every package. - Update dependencies deliberately by editing
requirements.inand re-compiling, never by manualpip install. - Use separate input files to manage development and production dependency sets.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.