Package managers
Tools that choose versions for a project’s dependencies, record the exact choice in a lockfile and install that same set on every machine.
3 technologies on this page ↓How it works
A project lists what it needs in a manifest (pyproject.toml, package.json, Cargo.toml), usually as ranges: pydantic>=2.14, or Cargo’s "0.1.12", which means >=0.1.12, <0.2.0. Each of those packages has its own ranges, so the package manager runs a resolver: it searches for one set of versions that satisfies every constraint at once, backing up when a choice leads to a conflict. In the worst case that search is NP-complete, which is why resolvers borrow tricks from SAT solvers (PubGrub, used by Dart’s pub and by uv) or avoid the search by design (Go’s minimal version selection). The answer goes into a lockfile (uv.lock, package-lock.json, Cargo.lock, poetry.lock) with the exact version of every package, direct and transitive, and usually its download URL and a hash of the file. Installs then read the lockfile instead of resolving again, so a laptop, CI and production get identical trees. The last job is isolation. pip and uv install a single version of each package into an environment, so each Python project gets its own virtual environment: a directory with its own site-packages on top of a shared interpreter. npm sidesteps the one-version rule by nesting a second copy inside node_modules when two packages need different versions; Cargo can link two semver-incompatible versions of a crate into one binary.
A recipe says “500 g of flour, any brand”. The receipt from today’s shop says exactly which brand and which bag. Cooks who follow the recipe get whatever is on the shelf that day; cooks who follow the receipt get exactly what you used.
Reach for it when
- Any application with third-party dependencies: commit the lockfile so laptops, CI and production install the same versions
- CI and deploys: install in locked mode (
npm ci,cargo build --locked,uv sync --locked) so a stale lockfile fails the build instead of being quietly rewritten - Several Python projects on one machine: one virtual environment each, so they can need different versions of the same library
Think twice when
- Expecting a library’s lockfile to protect its users: an application that installs the library resolves its ranges again and ignores the library’s lockfile
- Editing a lockfile by hand: uv, Cargo and Poetry all say it is generated and should not be changed manually
- Installing a project’s dependencies into the same environment as the package manager itself; Poetry’s docs warn it can then upgrade or remove its own dependencies
A real example
A Python service declares pydantic>=2.14 and locks it: uv.lock pins pydantic 2.14.0 and its four dependencies. A developer adds httpx>=0.28 to pyproject.toml by hand and pushes without relocking. CI runs uv sync --locked, which refuses: “The lockfile at uv.lock needs to be updated, but --locked was provided.” Running uv lock adds httpx 0.28.1 plus the five packages it pulls in (anyio, certifi, h11, httpcore, idna), and the next locked sync installs exactly those versions. Without the locked flag, CI would have resolved on its own and could have tested versions nobody had reviewed.
Technologies
Specific products, and what each does differently.
The same ideas in four ecosystems#
The file names differ; the split between a hand-written manifest and a generated lockfile is the same.
| Tool | Manifest | Lockfile | Install exactly what’s locked | Isolation |
|---|---|---|---|---|
| Python with uv | pyproject.toml | uv.lock (one universal file for every OS and Python version) | uv sync --locked | A .venv per project |
| Python with Poetry | pyproject.toml | poetry.lock | poetry install with the lockfile present | A virtualenv per project, by default in Poetry’s cache |
| JavaScript with npm | package.json | package-lock.json | npm ci | node_modules, nesting a second copy when versions conflict |
| Rust with Cargo | Cargo.toml | Cargo.lock | cargo build --locked | Per build; two semver-incompatible versions of a crate can coexist |
- pip can also write the standard
pylock.tomlformat (PEP 751) withpip lock, which was still experimental when checked.
Why resolution is hard, and how resolvers cope#
Picking versions sounds simple until two packages disagree. If A needs D version 1 and B needs D version 2, and only one D can be installed, there is no answer, and the resolver has to prove that and explain it. Russ Cox showed in 2016 that deciding whether any valid set exists is NP-complete by encoding 3-SAT as package versions.
Practical resolvers rely on that worst case being rare. pip has backtracked since version 20.3 (November 2020): it picks the newest version, follows its dependencies, and steps back when it hits a conflict, which can look like pip downloading many versions of one package. PubGrub, designed for Dart’s pub and reused by uv through pubgrub-rs, adapts conflict-driven clause learning from SAT solvers, so each conflict teaches it which combinations to skip and its error messages can explain why no solution exists.
Go took another route. Minimal version selection treats every requirement as a minimum, never a maximum, and chooses the oldest version that satisfies all of them, so no search is needed and builds use versions close to what the authors tested. Python and JavaScript ranges can have upper bounds, so their resolvers still search; uv offers --resolution lowest to test a library against its oldest allowed dependencies.
In code · bash
uv init --bare demo && cd demo # a pyproject.toml and nothing else
uv add 'pydantic>=2.14' # writes the range, resolves, creates uv.lock and .venv
# Resolved 6 packages ... + pydantic==2.14.0
grep -A1 '^name = "pydantic"' uv.lock
# name = "pydantic"
# version = "2.14.0"
# now add "httpx>=0.28" to dependencies in pyproject.toml by hand, without relocking
uv sync --locked # what CI runs; npm ci and cargo build --locked act the same way
# error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
# hint: To update the lockfile, run `uv lock`.
uv lock # Added anyio, certifi, h11, httpcore, httpx v0.28.1, idna
uv sync --locked # installs exactly what uv.lock lists; exit code 0Often confused with
Where it came from2010–2025
Early package managers installed whatever versions matched at the moment you ran them, so two installs of the same project a week apart could differ. Ruby’s Bundler made the lockfile a habit: Yehuda Katz explained in December 2010 that applications should commit Gemfile.lock, which pins every gem including dependencies of dependencies, while libraries should not, because their users resolve their own versions. Other ecosystems settled on the same split: Cargo tells applications to commit Cargo.lock, and npm 5 began writing package-lock.json automatically in May 2017.
Python grew the pieces separately. venv (Python 3.3, PEP 405) gave each project its own environment; pip installed packages but only gained a backtracking resolver in 20.3, in November 2020; pip-tools (pip-compile) and Poetry added lockfiles, each in its own format. A standard lockfile, pylock.toml, was accepted as PEP 751 in March 2025, and uv folded the resolver, installer, environments and Python itself into one tool.
Meanwhile the resolution problem itself got attention. Russ Cox proved in 2016 that version selection is NP-complete and in 2018 proposed minimal version selection for Go, which avoids the search. Dart’s pub built PubGrub to keep the search fast in practice and its failures readable.
Why it’s built this way
- Ranges in the manifest, exact versions in a lockfile
- Ranges let a project accept bug-fix releases and let libraries coexist; the lockfile freezes one tested answer so every machine installs the same thing until someone deliberately updates it.
- Hashes and URLs in the lockfile
- With the exact file and its hash recorded, an installer can check it got the same bytes and, as PEP 751 puts it, work out what to install without resolving again.
- One environment per project
- Python can import only one version of a package per environment, so isolation, not nesting, is how two projects use different versions. npm instead nests a second copy when two packages need different versions.
- Newest compatible versus minimal versions
- Most resolvers prefer the newest version that fits, to pick up fixes. Go chose the oldest version that satisfies every minimum, trading freshness for builds that match what authors tested and no search at all.
Timeline
- 2010
Bundler’s guidance: applications commit
Gemfile.lock, libraries don’t - 2012
Python 3.3 ships
venv(PEP 405) - 2016
Russ Cox proves version selection NP-complete
- 2017
npm 5 writes
package-lock.jsonby default - 2018
Go proposes minimal version selection
- 2020
pip 20.3 ships a backtracking resolver
- 2025
PEP 751 standardises
pylock.toml; pip 25.1 adds an experimentalpip lock
- Yehuda Katz: Clarifying the roles of the .gemspec and Gemfile (Dec 2010)
- PEP 405: Python virtual environments (Python 3.3)
- Russ Cox: Version SAT (Dec 2016)
- npm blog: v5.0.0 (May 2017)
- Russ Cox: Minimal version selection (Feb 2018)
- pip changelog: 20.3 (2020-11-30) and 25.1 (2025-04-26)
- pip docs: Dependency resolution (backtracking since 20.3)
- PEP 751: A file format to record Python dependencies for installation reproducibility (resolved March 2025)
- Dart pub: PubGrub version solving algorithm
- Astral blog: uv, unified Python packaging (Aug 2024)
Sources20
- Cargo Book: Specifying dependencies (default requirements)
- Cargo Book: Cargo.toml vs Cargo.lock
- Cargo Book: Dependency resolution (multiple versions of a crate)
- Cargo Book: cargo build (--locked)
- npm docs: package-lock.json
- npm docs: npm ci
- npm docs: Folders (nested node_modules, hoisting)
- uv docs: Project structure and files (the lockfile)
- uv docs: Locking and syncing (--locked, --frozen)
- uv docs: Resolution (universal resolution, lowest resolution)
- uv docs: Resolver internals (pubgrub-rs)
- Poetry docs: Basic usage (poetry.lock)
- Poetry docs: Introduction (install Poetry in its own environment)
- Python docs: venv
- pip docs: pip lock (experimental, writes pylock.toml)
- pip docs: Secure installs (hash-checking mode)
- pip docs: Dependency resolution
- Russ Cox: Version SAT (Dec 2016)
- Dart pub: PubGrub version solving algorithm
- PEP 751: A file format to record Python dependencies for installation reproducibility