Developer Tools · Package Managers
What a Lockfile Records and What It Leaves Out
How a lockfile turns one resolution result into an artifact the installer can consume without a solver, which facts it must store, and how an install checks that it still matches the manifest.
Two operations hide behind the word “install”. The first is a search: given a set of manifests and a registry, find one version per package that satisfies every constraint. The second is mechanical: fetch those versions, verify them, and place them where the language’s import machinery will find them. A lockfile exists to make the second operation independent of the first, so that a machine which has never run the solver can reproduce the exact set of packages another machine chose.
The artifact is a serialised assignment
A resolution result is a mapping from package identity to a version, plus the graph induced by those choices. Writing that mapping to a file is trivial; deciding what else must accompany it is not. The minimum a lockfile needs is enough information to fetch each package without consulting a range: the name, the exact version, and the source — a registry URL, a git revision, or a path. Everything else in the file is either a convenience for the installer, a security claim, or a record of a decision that cannot be recomputed.
The dependencies of each locked package are usually recorded too, as exact versions rather than ranges. This looks redundant, since the version is already in the file and its manifest could be fetched to learn its requirements. It is not redundant, for two reasons. First, the installer may want to verify the graph without downloading every package, which matters when a package is large or when it is optional on this platform. Second, the recorded edges are the edges the resolver actually used, which may differ from the edges in the published manifest if the registry’s metadata has since changed. Storing them freezes the graph as well as the versions.
The edge list is keyed by name, not by identity
A subtle but consequential choice is how the lockfile refers to a package’s children. The natural encoding is by identity, the same triple the resolver used internally. Most lockfile formats instead key the child by name, with the version stored alongside, because the installer’s view of the world is organised by name: it creates a directory per name, or an entry in a global store per name and version. The lockfile therefore sits between two models, and its shape usually reflects the installer’s.
Cargo’s lockfile makes the compromise visible. Each [[package]] entry carries a name, a version, a source, and, for registry packages, a checksum, plus a list of dependencies. Each dependency entry identifies the child by name and adds the version when more than one version of that name is present in the file; older revisions of the format also appended the source in parentheses for the cases where a name and version were still ambiguous.
[[package]]
name = "example-app"
version = "0.4.1"
dependencies = [
"parser 2.3.0",
"codegen 1.1.4",
]
[[package]]
name = "parser"
version = "2.3.0"
source = "registry+https://example.invalid/index"
The checksum field is a digest of the package archive as the registry serves it. Its presence changes the lockfile’s role: the file stops being only a record of a decision and becomes a claim about bytes. A registry that serves different content later will produce a mismatch rather than a silent upgrade, provided the installer actually checks. That check is the subject of Integrity Hashes and Registry Trust.
Layout leaks into the resolution record
npm’s second lockfile format stores a packages object keyed by installation path rather than by package name: the root is the empty string, and children appear as node_modules/foo and, where nesting is required, node_modules/foo/node_modules/bar. The key encodes where the installer will put the package, which is a property of the deduplication and hoisting algorithm rather than of the resolution.
This is a genuine design trade-off. Encoding layout makes the install deterministic with respect to placement, so two machines produce identical directory trees, and it lets the lockfile express two versions of the same package at different depths, which a name-keyed map cannot do without extra structure. The cost is that any change to the placement algorithm invalidates the format, and that the lockfile becomes specific to one installer’s layout rules. Formats that key by identity avoid that coupling but need an additional rule to describe which copy is visible to which dependent.
The general lesson is that a lockfile’s schema answers the question “what does the installer need”, and different installers need different things. A resolver-only reading of the file will find some fields inexplicable, and a resolver-only format will force the installer to recompute facts the solver already knew.
Checking that the lock still satisfies the manifest
A lockfile is only meaningful relative to the manifests it was produced from. When a manifest changes — a dependency added, a range widened — the lockfile becomes a stale witness: it records a consistent assignment, but not necessarily one that satisfies the current constraints. Installers therefore need a cheap check that the locked set is a model of the manifests.
The check is a restricted form of resolution and is much cheaper than resolution itself, because every version is already fixed. For each manifest requirement, look up the locked version of that name and evaluate the range against it; for each locked package, confirm that its recorded dependencies match the manifest of the locked version. If every check passes, the lock is usable as is. If any fails, the choices are to re-resolve or to refuse. Refusing is the behaviour that makes continuous integration trustworthy: a lockfile that silently drifts from the manifest defeats the purpose of committing it.
The cost of the check is proportional to the number of edges, and it requires no network access if the lockfile stores the dependency edges, which is one more argument for storing them. When the check fails, the re-resolution that follows is not incremental in most implementations: adding one dependency can shift versions across the graph, and the resulting diff can be far larger than the manifest change that triggered it. That asymmetry is why lockfile diffs deserve review, and why Lockfile Determinism and Merges treats the diff as a first-class artifact.
The split-file design
Not every ecosystem puts requirements and pins in one file. Go’s module system separates them: go.mod records the selected versions — the build list — and go.sum records cryptographic digests for module content. The separation reflects a different question. go.mod is both an input to and an output of resolution, since it holds the minimum versions the module requires and is updated when resolution raises them. go.sum is a pure verification record, holding two lines per module version: one digest of the module’s file tree, and one digest of its go.mod file alone.
The two-line structure is not arbitrary. The resolver needs the go.mod of many module versions to compute the graph, and it needs to verify those small files before trusting them to influence the build. Hashing the go.mod separately allows that verification without downloading the full module. The full-module digest is checked only when the source is actually extracted. A single combined digest would force a full download for every graph computation.
Splitting also means the two files have different update rules. go.sum accumulates entries and is normally append-only; removing an entry that is no longer needed requires an explicit tidy operation. go.mod is rewritten freely. Mixing them in one file would mean every resolution change rewrites the security record, which makes review harder rather than easier.
What the artifact cannot promise
A lockfile pins versions and, where digests are present, content. It does not pin the environment in which install scripts run, the version of the compiler that builds a native addon, the contents of files the package writes outside its install directory, or the order in which post-install steps execute. Two machines with identical lockfiles can produce different installed trees if a package’s install script behaves differently on each.
It also does not record why a version was chosen. A lockfile can show that package A is at 2.1.0 and that three packages require it, but not which requirement forced 2.1.0 over 2.0.0, nor which alternative assignments were rejected. That information exists inside the resolver during the search and is discarded when the result is written. Preserving it would make lockfiles much larger and would still be insufficient after the next manifest edit, since the reasons would then refer to constraints that no longer exist.
What remains is a compact, checkable statement: these versions, from these sources, with these digests, connected this way. Its value depends on the installer verifying it rather than treating it as advisory, and on the resolver producing the same file from the same inputs. The second property is not automatic, and it is the one that Lockfile Determinism and Merges is about.
References
- The Cargo Book: Cargo.toml vs Cargo.lock — Rust project
- npm package-lock.json documentation — npm project
- Go Modules Reference: go.sum — Go project