Constraints files in pip are often misunderstood as just another way to specify dependencies, but their real power lies in precisely controlling transitive dependencies – the packages your direct dependencies rely on.
Let’s see this in action. Imagine you’re building a web application and your requirements.txt looks like this:
Flask==2.3.0
requests==2.28.0
When pip install -r requirements.txt runs, it pulls down Flask and requests, but also their dependencies, and their dependencies’ dependencies, and so on. This can lead to version conflicts. For example, Flask might need Werkzeug<2.3 while requests might depend on charset-normalizer>=2.0 which in turn might pull in a Werkzeug>=2.3 that breaks Flask.
A constraints file, typically named constraints.txt (though the name is arbitrary), lets you lock down specific versions of these transitive dependencies without necessarily installing them directly.
Here’s how you’d use it. First, you’d generate a constraints.txt from a known good state. A common way to do this is after a successful installation:
# Install your direct dependencies first
pip install Flask==2.3.0 requests==2.28.0
# Freeze the *entire* dependency tree into a constraints file
pip freeze > constraints.txt
Now, constraints.txt will contain everything installed, including Flask, requests, and all their sub-dependencies, each with a specific version. For instance, it might look like this:
click==8.1.3
charset-normalizer==3.1.0
Flask==2.3.0
idna==3.4
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
requests==2.28.0
urllib3==1.26.15
Werkzeug==2.2.3
The key is how you use this file. You don’t pip install -r constraints.txt. Instead, you tell pip to respect these versions during installation:
pip install -r requirements.txt --constraint constraints.txt
When pip processes requirements.txt, it sees Flask==2.3.0 and requests==2.28.0. As it resolves their dependencies, it checks constraints.txt. If constraints.txt specifies Werkzeug==2.2.3, pip will enforce that version, even if Flask’s metadata allows a newer Werkzeug (e.g., Werkzeug>=2.2). This prevents conflicts by ensuring all packages align with the versions specified in constraints.txt.
This mechanism is invaluable for creating reproducible builds and stable environments. You can ship requirements.txt (your direct dependencies) and constraints.txt (your locked-down transitive dependencies) together. Consumers of your project can then install with confidence, knowing they’ll get the exact same dependency graph you tested.
The mental model shifts from "what do I want to install?" to "what versions are acceptable?" The constraints file acts as a set of rules that pip must follow when resolving dependencies, rather than a list of packages to install.
A common, albeit less intuitive, way to leverage constraints is by pointing to a URL or a VCS repository. This allows you to maintain a single source of truth for your constraints across multiple projects. For example:
# In requirements.txt for Project A
-r https://example.com/shared-constraints.txt
some-package==1.0.0
This shared-constraints.txt could contain all your core, stable dependencies. When pip encounters this line, it fetches and applies those constraints before processing some-package==1.0.0. This ensures that some-package is installed respecting the versions defined in your shared constraints, even if some-package’s metadata suggests a different, incompatible version.
The most surprising thing about pip constraints is that they don’t just recommend versions; they enforce them. If a direct dependency requires a version of a transitive dependency that conflicts with a version specified in the constraints file, pip will fail. This strictness is precisely what makes them powerful for stability.
The next step in managing complex dependency graphs often involves looking at tools like Poetry or Pipenv, which integrate dependency resolution and locking more deeply.