How pip’s dependency resolver picks versions
pip's dependency resolver is the part of pip that decides which version of every Python package gets installed when you run pip install .... Until pip 20.3, the resolver was famously permissive — it would accept conflicting requirements and install something that might not actually satisfy them. From 20.3 onward pip has a real backtracking resolver. This post explains how it works, what changed, and the practical implications for builds and security.
What "resolver" means in pip
When you give pip a list of requirements (whether from the command line, a requirements.txt, or a pyproject.toml), each requirement is a name plus a version specifier. pip's job is to pick a single version for each name in the transitive closure of dependencies such that every requirement is satisfied.
Each candidate version of a package itself declares dependencies in its metadata. Walking the graph, pip needs to find a set of versions that simultaneously satisfies every requirement.
Before pip 20.3
The legacy resolver visited each requirement in order and picked a version that satisfied that requirement, ignoring whether the choice would later conflict. If two parents requested the same package at incompatible versions, pip would install the second-named version on top of the first, leaving the environment in a state that violated at least one declared requirement.
The result was that pip install would frequently produce environments that were technically broken but did not error out. Users learned to ignore warning lines or to manually pin versions to dodge known conflicts.
The new resolver
pip 20.3+ ships a backtracking resolver based on the same ideas as Cargo and Yarn. It treats version selection as a search problem: try candidates, detect conflicts, backtrack, try alternatives, fail loudly if no consistent assignment exists.
Three properties of the new resolver matter in practice.
Conflicting requirements now error. If you ask for requests==2.28 and a transitive dependency requires requests<2.0, pip refuses to install rather than silently picking one. The error message lists the conflict.
Resolution can be slow. Backtracking through a deep tree with many candidate versions takes time. For large requirement sets, pip downloads many candidate metadatas before settling. This is visible as a long pause during pip install for large projects.
Order of requirements still matters slightly. When the resolver has freedom to pick, the order requirements appear in requirements.txt or pyproject.toml can affect which valid solution is found.
How candidates are scored
For each package, pip considers the candidate versions in highest-version-first order, modulo two constraints:
- Pre-releases are excluded by default. You include them with
--pre. - Versions that don't satisfy the active marker (Python version, platform) are skipped.
When backtracking is required, pip drops back to an earlier choice and tries the next-best candidate.
This means pip generally prefers the highest version that everything agrees on. Where multiple combinations are valid, you usually get the latest set.
Index sources and the resolution surface
pip consults the configured indexes when fetching package metadata. The two relevant flags:
--index-url: the primary index URL (defaults to PyPI).--extra-index-url: additional indexes consulted alongside.
--extra-index-url is the dependency-confusion trap. pip treats both indexes as sources for the same flat namespace; if the same package name appears on both, pip picks the higher version regardless of which index it came from. Use --index-url (single source) or --index-strategy unsafe-best-match and --index-url carefully when mixing internal and public indexes.
Modern alternatives — uv, poetry, pip-tools — support per-package index pinning (tool.poetry.source with priority = "explicit", uv's [[tool.uv.index]] explicit = true). If you have an internal registry, prefer one of those.
Lockfiles for pip
pip itself does not produce a lockfile. The conventional approach is pip-compile (from pip-tools), which takes a high-level requirements.in and produces a fully-resolved requirements.txt with exact versions and SHA-256 hashes for every package.
pip-compile --generate-hashes requirements.in -o requirements.txt
pip install -r requirements.txt --require-hashes
--require-hashes makes pip refuse to install if any package's downloaded artifact does not match the recorded hash. This is the closest pip equivalent of npm ci's integrity-checked install.
Why this matters for security
A backtracking resolver that can fail loudly is better than a permissive resolver that silently produces broken environments. But security review still depends on knowing what was actually installed.
Two practices follow.
Always pin and hash. Use pip-compile --generate-hashes (or uv's lockfile, or Poetry's lockfile) so a pip install from your locked file produces the same artifacts byte-for-byte across machines.
Treat resolver upgrades like code changes. When pip-compile regenerates and the diff brings in new transitive versions, that diff is a code change. Review it; do not auto-merge.