Software Engineering · Dependencies
Lockfiles: Recording the Resolved Graph Without Re-Resolving It
What a lockfile stores beyond version numbers, how it stays consistent with the manifest, and why regeneration is a different operation from installation.
A manifest declares what is acceptable. A lockfile records what was chosen. The distinction looks like bookkeeping, but it changes the meaning of the build: without a lockfile, two installations of the same commit can produce different programs, because the resolver runs again and the set of published versions has grown since the last run.
The interesting questions about lockfiles are not whether to commit them — that follows from whether the artifact is an application or a library — but what exactly must be stored so that a later installation reproduces the earlier resolution without consulting the resolver at all, and what happens when the manifest and the lock disagree.
The resolved graph is more than a version list
The naive model is that a lockfile maps each package name to a version. That is insufficient in every ecosystem that supports more than one copy of a package, because the same name can appear at several versions in a single graph. A flat map cannot represent lodash@4.17.21 used by one subtree and lodash@3.10.1 used by another, and flattening to one of them silently changes the program.
The structure that works is a directed acyclic graph, sometimes serialised as a flat table of nodes keyed by a path or by a name-plus-version-plus-context triple, with edges representing the requirement that introduced them. A node records at minimum: the exact version, the source location it came from (registry, git revision, local path, tarball URL), the integrity digest of the artifact, and the requirements that this node itself declares. The last item is what makes offline re-resolution possible: given a node, the installer can walk its requirements without fetching metadata.
Consider a lockfile node in the shape used by several ecosystems:
{
"packages": {
"node_modules/liba": {
"version": "2.4.1",
"resolved": "https://registry.example/liba-2.4.1.tgz",
"integrity": "sha512-<base64 digest>",
"dependencies": { "core": "^1.0.0" },
"engines": { "node": ">=18" }
},
"node_modules/libb/node_modules/core": {
"version": "1.9.3",
"integrity": "sha512-<base64 digest>"
}
}
}
Two details carry most of the weight. The resolved field pins the artifact location, so a lockfile survives a registry that reorders or aliases tarballs, and so a git dependency records the commit rather than a branch. The integrity field pins the bytes: it is a hash of the tarball as distributed, and the installer recomputes it after download and refuses on mismatch. Without that field, a lockfile fixes which version is installed but not which content arrives under that version, which is exactly the gap an attacker who can replace a published artifact would use. The hash algorithm is named in the field, and the ecosystem’s choice is visible in the prefix — sha512 is the common case because it is cheap enough at tarball sizes and wide enough to make collisions implausible.
Version and integrity answer different questions. Version selects the code the publisher intended; integrity selects the bytes the publisher actually served. A registry that allows republishing the same version number breaks the first guarantee while leaving the second intact, which is why the digest is the load-bearing field.
Which requirements go into the file
A lockfile is generated from a manifest, but the manifest does not contain enough information to regenerate it. The manifest holds direct requirements; the lock holds the transitive closure, with each node’s own requirements recorded.
The consequence is a consistency obligation in both directions. Adding a direct dependency to the manifest without updating the lock leaves the lock incomplete, and an installer reading the lock will either fail or, worse, silently install only what the lock describes. Removing a direct dependency while leaving the lock untouched leaves stale nodes; a correct installer prunes them, but pruning requires knowing which nodes are reachable from the current root set, which is a graph traversal over the lock itself.
Ecosystems handle the mismatch differently, and the difference is a design choice with visible consequences. Some tools treat the lock as authoritative and install from it, reporting a manifest change as an error requiring explicit regeneration. Others reconcile automatically: they re-resolve only the changed portion of the graph and update the lock, which preserves pinned versions for untouched subtrees. The second behaviour is more convenient and less predictable, because it can change a transitive version without any manifest edit — an upstream requirement’s range admitting a newly published version is enough. That is the mechanism by which a lockfile changes without anyone touching the project.
A third behaviour, common in library repositories, ignores the lock entirely when the project is consumed as a dependency, because the consuming project’s lock governs. This is why a lockfile committed in a library is useful for that library’s own CI and not for its users.
Frozen installation and the offline path
The operational distinction that matters is between an installation that is allowed to resolve and one that is not. A frozen installation reads the lock, verifies every node’s digest, and fails if the lock and manifest disagree or if any required node is missing. It never contacts a metadata endpoint; it fetches artifacts by exact URL.
Frozen mode is what makes a build reproducible across machines and time. It also makes the failure mode legible: the build fails at the point where the lock is inconsistent with the manifest, rather than succeeding with a graph nobody has reviewed.
The offline variant goes further and requires that the artifacts be present locally, in a cache or a vendored directory. Vendoring, in its strict form, copies the exact contents of every node into the repository. It guarantees availability without any network access at build time, at the cost of repository size and of making upgrades a two-step operation: change the manifest, regenerate the lock, re-vendor.
Caching is the middle ground. A content-addressed cache keyed by the integrity digest means that a cache hit is provably the same bytes, so a build can proceed from cache without trusting the network. Cache misses fall back to fetching by resolved URL. The correctness of the whole scheme rests on the digest being checked after retrieval rather than assumed from the cache key, since a corrupted cache entry is otherwise indistinguishable from a valid one.
Regeneration is a decision, not a side effect
Updating the lock is a resolution run, and it carries all the properties of resolution as a search. Whether the tool re-resolves everything or only what changed is a policy choice, and the two policies produce different graphs from the same inputs.
Full re-resolution discards all previous choices and picks the best assignment under current constraints. It maximises freshness and is the only way to discover that a newer transitive version is compatible. It also produces the largest diff, and a large lockfile diff is hard to review, which means it is usually reviewed by not being read.
Incremental re-resolution keeps prior choices where they remain valid and only recomputes where the constraints changed. Smaller diffs, more reviewable, but it can preserve a stale choice indefinitely: a transitive package that was pinned for a compatibility reason two years ago stays pinned because nothing forces a reconsideration.
A third policy, sometimes called “update one at a time”, regenerates with a single named package allowed to move. This produces the smallest possible diff per change and makes the cause of any breakage attributable, at the cost of many more operations to reach the same end state.
The point where the policies diverge observably is a lockfile diff. A version change on a deeply transitive package appears in all three, but only full re-resolution will surface that the change was available at all if nothing in the manifest moved.
Merge conflicts in a generated file
Lockfiles are generated, machine-ordered, and frequently large, which makes them merge-hostile. Two branches that each add a dependency produce conflicts in the node table, and hand-resolving them is error-prone because the correct resolution is a graph property, not a textual one. Picking both sides of a conflict can produce a lock that references a node twice under different keys, or that has a node whose requirements are inconsistent with its recorded dependencies.
The reliable procedure is to take either side, restore the manifest to the merged state, and regenerate. That discards the textual conflict entirely and recomputes a consistent graph, which is the only operation guaranteed to produce a valid file. The cost is that the regeneration may move versions unrelated to the merge, so the resulting diff is larger than the semantic change warrants.
Some formats reduce this pressure by design: sorting nodes deterministically, keying by path rather than by traversal order, and storing one node per line so that independent additions land on independent lines. Deterministic serialisation is also what makes a lockfile diff meaningful at all — if the generator’s output order depends on hash iteration order or on insertion sequence, two identical graphs serialise differently and every regeneration produces spurious changes.
What the lockfile still does not pin
Three things remain outside its reach.
It does not pin the toolchain. A lockfile describes packages; the compiler, interpreter, or build tool that consumes them is pinned separately or not at all. A build that is byte-identical in its dependency graph can still differ in output across toolchain versions.
It does not pin the resolver’s own behaviour. Two resolver versions given the same manifest and the same published versions may select different graphs, because the selection heuristic is not specified. Freezing the lock avoids the issue for installations but not for regenerations, and it is why a regeneration should be treated as a change to review rather than a routine refresh.
And it does not pin what the packages do at install time. Post-install scripts, build steps, and native compilation run after the graph is materialised and are governed by a separate trust boundary, discussed under install-time lifecycle execution. The lockfile’s guarantee is that the same source bytes arrive; what those bytes then execute is outside the guarantee.
References
- npm package-lock.json documentation — npm documentation
- Cargo Reference: Cargo.toml vs Cargo.lock — The Cargo Book
- PEP 751: A file format to record Python dependencies for installation reproducibility — Python Software Foundation