DevOps · Release Engineering
Lockfiles and How a Version Range Becomes a Tree
Why a range in a manifest is not a pin, what a lockfile records that the manifest cannot, and how resolvers settle or fail on conflicting requirements.
A manifest says what a project is willing to accept. A lockfile says what it actually got. The difference is the entire reproducibility story of modern dependency management, and it exists because of an asymmetry that ranges cannot express: requirements compose, but resolutions do not.
Two packages each declare >=1.2.0 <2.0.0. The intersection is broad, and every version inside it satisfies both. Which version gets installed is decided by the resolver at a particular moment against a particular registry state, and nothing in the two manifests records the choice. Run the install again next month and a new minor release inside the range may be selected. The manifest is satisfied both times; the build is different.
What the manifest cannot carry
A manifest’s ranges cover each dependency’s own requirements, not the shape of the resulting graph. Three things fall outside what it can express:
The version chosen. A resolver picks one version per package name (or per name and major, depending on ecosystem). The choice is a solution to a constraint satisfaction problem, not a projection of any single requirement.
The set of packages. Transitive dependencies appear in the graph without being named in the manifest. A new package can enter the graph because some dependency added a requirement in a minor release, and the manifest’s author never saw it.
Content hashes. A range cannot pin bytes. Registries can serve a different artifact for the same version, and a manifest cannot detect it. A hash can.
The lockfile records all three. Its structure varies by ecosystem, as a nested tree in one and a flat list of name/version/hash triples in another, but each is a serialisation of one resolved graph plus the integrity data needed to detect substitution.
Resolution as a backtracking search
For a single package the answer is simple: pick the highest version satisfying the constraint. With a graph, the choice for one package changes what other packages require, and can invalidate a choice already made. The general problem is NP-hard for arbitrary constraint systems; real resolvers manage it with heuristics that work well on real registries.
The shape of the algorithm:
- Take the frontier of unsatisfied requirements, keyed by package name.
- For one package, collect the union of constraints from every requirer in the partial solution.
- Intersect them and select a candidate version, usually the highest that satisfies the intersection.
- Add that version’s own dependencies as new requirements.
- If the intersection is empty, backtrack: reject the most recent choice that contributed to the conflict and try the next-lower candidate.
A worked conflict is worth tracing. Suppose application A requires lib >=2.0, and lib 2.1 requires util ^3, while A separately requires util ^2. Selecting lib 2.1 introduces a requirement on util 3.x that contradicts util 2.x. There is no version of util satisfying both, so the resolver must backtrack on lib and try lib 2.0, which may require util ^2 and resolve cleanly.
The order in which the frontier is processed determines how fast the search finds a solution and sometimes whether it finds one at all within its budget. Depth-first with the most-constrained package first finds conflicts early; breadth-first explores more of the space before failing. Both are correct only in the sense that a returned solution satisfies all constraints. When they give up they report a conflict, and the reported conflict is not necessarily the cause — it is the point at which the search exhausted its budget.
Diamond dependencies and the two strategies
The util example above is a diamond: two paths to the same package with incompatible requirements. Ecosystems answer it in one of two ways.
One version per package, globally. The resolver must find a single version satisfying every requirer, or fail. This keeps the installed tree unambiguous and makes type identity work — two copies of a class in a language with nominal typing is a runtime error waiting to happen. It also means an old constraint anywhere in the graph can block an upgrade everywhere, which is why deep transitive pins calcify.
Multiple versions side by side. Each requirer gets the version it asked for, nested under its own subtree. This makes resolution almost always succeed, at the cost of duplicate code, duplicated global state, and the surprising situation where a fix in a transitive dependency must be applied to several installed copies.
The choice is a language-level constraint more than a policy one. A resolver cannot unify types that the runtime keeps distinct, and cannot keep distinct what the runtime identifies by name.
Integrity and the reproducibility boundary
A lockfile with versions but no hashes still reproduces the graph; it does not reproduce the bytes. The hash closes that. When an entry records a content digest, the installer verifies what it downloaded and refuses a mismatch, which turns a silent substitution into a hard failure.
What the lockfile still does not cover is the resolution of anything it does not list, and anything that executes at install time. A lifecycle script that fetches a tool from the network runs after the lockfile has done its job. Native compilation pulls headers from the host. This is why reproducible builds treat the lockfile as a necessary but insufficient input, and why build environments need pinned base images as well; the wider question of what a release actually contains is taken up in immutable artifact coordinates and provenance.
A second boundary is the resolver itself. A lockfile records the output of one resolver version’s decisions. A different resolver, or a newer version of the same one, may interpret a range differently, especially around pre-release selection, where ecosystems disagree about whether a range without a pre-release component may match one. Re-resolving with a different tool can therefore change the graph without any manifest change. The channel and identifier conventions that govern those pre-release cases are set out in pre-release channels and promotion in package registries.
Updating a lockfile is a reviewable act
Because the lockfile is a generated artifact committed to the repository, its diff is the record of what changed in the dependency graph. Reading that diff is the actual audit. Three patterns appear in nearly every update:
- A version bump under an unchanged range. Routine, and the reason automated update pull requests exist: they apply the bump in isolation so the diff is small and the test suite runs against exactly one change.
- New transitive entries. A dependency added a dependency. Nothing in the direct manifest changed; the graph grew. This is where unreviewed surface enters.
- Removals and version downgrades. Often a sign of unification: the resolver collapsed two copies of a package into one already-present version to satisfy a new constraint. A downgrade in a lockfile is not necessarily a regression, and its presence usually means a constraint elsewhere tightened.
Deleting the lockfile to “fix” a resolution failure discards exactly the information that made the build reproducible, and re-resolves the entire graph against current registry state. The failure disappears because the pinning disappeared. The correct repair is a targeted change to the conflicting constraint or a deliberate exclusion, leaving the rest of the graph as it was.
Where a lockfile is binding
A published library should generally not ship its lockfile as authoritative, because its consumers resolve against their own graph; the library’s resolution is not binding on them. An application or a deployment image should, because there the resolved graph is the artifact. The distinction is the same one that decides whether a version number describes a surface or a deployment, discussed in semantic versioning as a compatibility contract.
The operational consequence is that lockfile drift becomes a monitored property. A change to the file on a branch with no manifest change means a dependency moved. No change across a rebuild in a clean environment means the build inputs are pinned. That second property is the only claim worth making about a lockfile, and it is what a release gate can actually verify: re-resolve from the manifest in a clean checkout, and fail if the result differs from the committed file. A gate that merely checks the lockfile exists tests nothing.
References
- RFC 2119: Key words for use in RFCs to Indicate Requirement Levels — IETF
- pip documentation — Requirements file format — Python Packaging Authority
- Cargo book — Cargo.toml vs Cargo.lock — Rust project
- npm documentation — package-lock.json — npm project