How to pin and lock dependencies¶
When should I pin a dependency, and what's the difference between pinning and locking?¶
The right answer depends on whether you're shipping a library or an application — and on how strict you need reproducibility to be.
The answer¶
| Situation | What to do |
|---|---|
| You're writing a library that others will install | Use ranges in pyproject.toml (requests>=2.30,<3). Don't pin exactly. |
| You're shipping an application to users or production | Range in pyproject.toml for declared deps; lock file with exact versions for reproducible installs. |
| You're prototyping locally | Pin or not — it doesn't matter much. Just don't push unpinned to production. |
The two operations look similar but answer different questions:
- Pinning is a declaration: "this package is compatible with these versions of its dependencies." It lives in
pyproject.toml(orrequirements.txt). - Locking is a recording: "on this date, when we resolved the dependency graph, here's the exact set of packages and exact versions we got." It lives in a lock file —
requirements.lock,uv.lock,poetry.lock, etc.
Why it works¶
Libraries should use ranges, not exact pins, because their pyproject.toml becomes part of the dependency graph for every application that installs them. If your library pins requests==2.31.0 and another library in the same project pins requests==2.32.0, pip can't satisfy both — the install fails. Ranges (requests>=2.30,<3) leave room for the resolver to find a single version that works for everyone.
Applications need both. The pyproject.toml (or requirements.txt) declares your direct dependencies and acceptable ranges, mirroring the library style. But for deployments, you also want to record the exact versions you tested with — including the indirect dependencies you never named. That's the lock file's job. Without one, two installs a week apart can produce two different sets of installed packages, and any bug that depends on transitive versions becomes a moving target.
The compatible release operator (~=) is a useful middle ground. ~=2.31.0 means "at least 2.31.0, but less than 2.32.0" — patch updates only. ~=2.31 means "at least 2.31, but less than 3.0" — minor updates allowed. Use it when you want to track bug fixes but not feature releases.
How to do it¶
For a library — ranges in pyproject.toml¶
Test against the lower and upper bounds you declare. If you declare >=2.30, ensure your CI runs against 2.30; if you declare <3, you're claiming 3.x might break and you've not yet validated against it.
For an application — requirements.txt with a lock file¶
The simplest workable setup uses two files:
requirements.in— your direct, ranged declarations.requirements.txt— the locked output, with exact versions of everything (direct and transitive), generated by a tool.
pip-tools is the long-standing way to generate one from the other:
pip install pip-tools
pip-compile requirements.in # writes requirements.txt with exact versions and hashes
pip-sync # installs exactly what's in requirements.txt
uv does the same job faster, with one command per workflow:
Either way, commit the lock file. Anyone who clones the repo and runs pip install -r requirements.txt then gets exactly the versions you tested with.
Updating the lock¶
Don't hand-edit the lock file. Let the tool regenerate it:
pip-compile --upgrade requirements.in # all packages to latest allowed by ranges
pip-compile --upgrade-package requests # just one
Review the diff, run your test suite, and commit.
Trade-offs¶
A few rules have edges worth knowing.
Pinning your own library exactly will eventually bite someone. Unless you have a very good reason — a known incompatibility you've documented — leave room. The cost of a too-loose range is your library breaks for some users; the cost of a too-tight range is your library is uninstallable for some users.
Lock files don't capture everything. A lock file fixes package versions but not the Python version, the operating system, or any system libraries (compilers, OpenSSL, libpq) that wheels depend on. For the reproducibility a lock file can't provide, look at containers or nix.
Hashes are optional but worth turning on for production. pip-compile --generate-hashes adds a content hash for every package. pip install --require-hashes then refuses any download whose hash doesn't match — a useful guard against package-index tampering.
Range syntax is its own footgun. >=2.30 and >=2.30.0 are not the same to all resolvers; ~=2 is invalid (the ~= operator needs at least two version components). When in doubt, write the range out longhand: >=2.30,<3.
Related¶
- Installing third-party packages — the consumer side, where pins and ranges first show up.
- pyproject.toml field reference — the
dependenciesandoptional-dependenciesfields in detail. - The PyPI ecosystem and trust — why hashing and pinning matter for supply-chain risk.