Back to glossary

Lockfile

A lockfile records the exact version, source, and hash of every resolved dependency, so later installs replay the same tree instead of resolving again.

Also known as lock file, lockfiles, lock files, frozen lockfile, dependency lockfile

A lockfile (also written lock file) is a generated file, such as package-lock.json, pnpm-lock.yaml, uv.lock, or Cargo.lock, that records the exact version, source, and hash of every package a resolver selected. Strict installs replay that tree, so a newly published version cannot enter silently. A lockfile makes installs repeatable. It does not make them safe.

Lockfiles exist because most resolvers choose the newest version that satisfies each range, and the newest version changes whenever someone publishes. In the axios compromise on March 31, 2026, axios 1.14.1 became the answer to every ^1.13.5 range the moment it reached npm. As we documented in the hidden blast radius of the axios compromise, projects whose lockfile already pinned an earlier axios, and that installed in a mode that respected it, were likely protected. Fresh installs, npx runs, and global installs were not.

Our position: commit a lockfile for everything you deploy or run in CI, install from it with an explicit strict flag, and review changes to it as code. Then stop expecting more from it. A lockfile governs only the installs that read it. It freezes whatever was chosen, good or bad. And three of the five package managers we tested installed a tampered lockfile entry without complaint.

Why resolvers need a lockfile

#

A manifest such as package.json, pyproject.toml, or Cargo.toml declares ranges. "debug": "^4.3.0" accepts any 4.x release at or above 4.3.0. Ranges let a library accept compatible fixes without a new release, and they let a package manager share one copy of a dependency among many dependents. Semantic versioning is the convention that makes that bet reasonable.

The cost is that a manifest does not name a tree. npm, pnpm, Yarn, Bun, pip, uv, Poetry, Cargo, and Bundler all prefer the highest version that satisfies a range by default. Resolution therefore depends on the registry's state at the moment it runs, and it repeats for every transitive dependency in the graph:

Bash
$ npm view 'ms@^2.0.0' version
ms@2.0.0 '2.0.0'
ms@2.1.0 '2.1.0'
ms@2.1.1 '2.1.1'
ms@2.1.2 '2.1.2'
ms@2.1.3 '2.1.3'

Today ^2.0.0 means 2.1.3. If 2.1.4 is published in an hour, the same manifest means 2.1.4, with no change in your repository.

Go is the instructive exception. Go's minimal version selection uses the lowest version that satisfies every requirement in the build, so a new release changes nothing until someone raises a requirement. The Go modules reference states it directly: "MVS is deterministic, and the build list doesn't change when new versions of dependencies are released." Go has no lockfile. Its go.sum file stores checksums, not choices.

Every other major ecosystem kept the newest-wins resolver and added a lockfile instead: run the resolver once, write down its answer, and replay the answer until someone asks for a new one.

What is a lockfile?

#

A lockfile is a resolver's output written to disk. Each entry names one installed package, its exact version, where it was fetched from, and a hash of its contents. The manifest says what you accept. The lockfile says what you got. Package managers write lockfiles, and people are not expected to edit them by hand.

Here is one resolution, ms@^2.1.0 to 2.1.3, as four JavaScript package managers recorded it on November 3, 2026:

Plain text
# package-lock.json (npm 12.2.1)
"node_modules/ms": {
  "version": "2.1.3",
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}

# pnpm-lock.yaml (pnpm 12.16.1)
ms@2.1.3:
  resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}

# bun.lock (Bun 1.4.4)
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

# yarn.lock (Yarn 4.18.1)
"ms@npm:^2.1.0":
  version: 2.1.3
  resolution: "ms@npm:2.1.3"
  checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48

npm, pnpm, and Bun record the same value: the registry's SHA-512 digest of the tarball, the one npm view ms@2.1.3 dist.integrity prints. Yarn records something else. Its 10c0/ checksum is the SHA-512 of the zip archive Yarn builds in its own cache. We confirmed that by hashing ~/.yarn/berry/cache/ms-npm-2.1.3-81ff3cfac1-10c0.zip, which matched the checksum exactly. A Yarn checksum cannot be compared with registry metadata. Only Yarn can recompute it.

package-lock.json has its own entry with a field-by-field walkthrough. The table below compares lockfile formats across ecosystems. We tested the first eight rows on November 3, 2026, with the versions shown; the last column records what each default install command did after we added a dependency to the manifest without updating the lockfile. The Go and Bundler rows come from their documentation, because our test environment could not reach those registries.

Ecosystem (version tested)LockfileWhat the hash coversStrict installDefault install when the manifest drifts
npm 12.2.1package-lock.jsonRegistry tarball, SHA-512npm ciRewrites the lockfile, even with CI=true
pnpm 12.16.1pnpm-lock.yamlRegistry tarball, SHA-512pnpm install --frozen-lockfileRewrites it locally; fails in CI
Yarn 4.18.1yarn.lockYarn's cache zip, SHA-512yarn install --immutableRewrites it locally; fails in CI
Bun 1.4.4bun.lockRegistry tarball, SHA-512bun install --frozen-lockfileRewrites the lockfile, even with CI=true
uv 0.11.32uv.lockEach sdist and wheel, SHA-256, plus upload timeuv sync --lockedRewrites the lockfile
Poetry 2.4.3poetry.lockEach sdist and wheel, SHA-256poetry syncFails
pip 26.2.1pylock.toml (experimental)Each file, SHA-256pip install -r pylock.tomlNot applicable: pip only reads a lockfile you generate
Cargo 1.97.0Cargo.lock.crate archive, SHA-256cargo build --lockedRewrites the lockfile
GoNone. go.mod selects, go.sum verifiesModule tree and go.mod, SHA-256Default behaviorFails, since Go 1.16
BundlerGemfile.lock.gem file, SHA-256, in new lockfilesBUNDLE_FROZEN=trueRewrites the lockfile unless frozen is set

Python gained a standard format only recently. Python's adoption of a standard lock file format, PEP 751, was accepted on March 31, 2025, and defines pylock.toml. As of pip 26.2.1, pip lock still prints "EXPERIMENTAL" in its help text. In our run it recorded only the wheels for the machine it ran on, while uv.lock and poetry.lock list every published file.

Should you pin dependency versions?

#

Yes, you should pin dependency versions: pin every application's full tree with a committed lockfile, add exact pins in the manifest only where you want each upgrade to appear as a visible diff, and keep ranges in libraries you publish. "Pinning" names three different controls, and each one stops at a different depth.

An exact version in the manifest pins one level. "debug": "4.4.3" fixes debug, but debug 4.4.3 itself declares "ms": "^2.1.3", so ms still floats:

Bash
$ npm view debug@4.4.3 dependencies
{ ms: '^2.1.3' }
$ npm config get save-prefix
^

npm's default save-prefix is ^, so npm install debug writes a range unless you pass --save-exact. Only a lockfile pins the whole graph, including the transitive dependencies you never chose.

ApproachWhat it pinsWhat it stopsWhat it does not coverUse it for
Wildcard or open range: *, "", >1NothingNothingEvery future release, including new major versionsNever
Caret or tilde range: ^4.3.0, ~4.3.0The major or minor lineBreaking changes, if the maintainer follows semverMalicious or buggy releases inside the rangeLibraries you publish
Exact pin in the manifest: 4.4.3Direct dependencies onlySilent upgrades of direct dependencies; makes each bump a reviewable diffTransitive dependencies; duplicate copies for downstream users of a libraryApplications, and CLIs published to a registry whose lockfile never reaches users
Lockfile plus a strict installThe whole treeNewly published versions entering an existing installNew dependencies, npx and global tools, poisoned entries, versions that were bad when lockedEvery application and every CI job

Verdict: the lockfile is the control that matters, and it belongs in every repository you build from. Exact manifest pins add review visibility for applications and published CLIs. Ranges stay correct for libraries, because a library that pins forces its exact versions on every consumer. Wildcards are never correct. Socket's Wildcard dependency alert flags packages that depend on *, an empty version string, or a greater-than range such as >1.

Libraries are where the guidance has moved. npm never publishes package-lock.json: even with the file listed in files, npm pack on npm 12.2.1 left it out of the tarball. Cargo can ship Cargo.lock inside a published crate, but cargo install ignores it unless you pass --locked. In August 2023 the Rust project stopped advising libraries to leave Cargo.lock uncommitted, and cargo new began tracking it for libraries. Ed Page wrote on the Rust blog: "Regardless of what decision projects make, we encourage regular testing against their latest dependencies."

We agree with that split. A library should commit its lockfile so its own CI is reproducible. It should also run a scheduled job that resolves fresh, such as npm install --no-package-lock, cargo update, or uv lock --upgrade, because that is the install its users actually get.

Applications need a lockfile without exception. Since June 16, 2026, Socket's Manifest Alerts report application repositories that contain a manifest with no corresponding lockfile. The "Missing lockfile" alert is scoped to applications and does not ask published libraries to ship one.

What does pnpm install --frozen-lockfile do?

#

pnpm install --frozen-lockfile installs exactly what pnpm-lock.yaml records and never writes the lockfile. It fails with ERR_PNPM_OUTDATED_LOCKFILE if the lockfile is missing or no longer matches package.json. The pnpm documentation sets the flag to true by default in CI "if a lockfile is present," detected the same way as the ci-info package. pnpm 12.16.1 also provides pnpm ci, which cleans node_modules and then runs a frozen install.

Every package manager has an equivalent mode, and the names collide. We added one dependency to each manifest without updating its lockfile, then ran each strict command:

Plain text
$ npm ci
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: debug@4.4.3 from lock file                          # exit 1

$ pnpm install --frozen-lockfile
Error: ERR_PNPM_OUTDATED_LOCKFILE
  Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with package.json.

$ yarn install --immutable
➤ YN0028: │ The lockfile would have been modified by this install, which is explicitly forbidden.

$ bun install --frozen-lockfile
error: lockfile had changes, but lockfile is frozen

$ uv sync --locked
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.

$ uv sync --frozen
Checked 5 packages in 0.09ms                                          # exit 0, new dependency not installed

$ poetry sync
pyproject.toml changed significantly since poetry.lock was last generated. Run `poetry lock` to fix the lock file.

$ cargo build --locked
error: cannot update the lock file Cargo.lock because --locked was passed to prevent this   # exit 101

The word "frozen" means three different things. In pnpm and Bun, --frozen-lockfile fails on drift. In Cargo, --frozen means --locked plus --offline. In uv, --frozen skips the freshness check entirely: uv sync --frozen exited 0 and installed the stale lockfile without the dependency we had added. uv's own help text describes --frozen as "Sync without updating the uv.lock file," and the flag that fails on drift is --locked.

Of the four JavaScript package managers, only pnpm and Yarn switch to strict mode on their own in CI. With CI=true set, npm install and bun install rewrote their lockfiles and exited 0. uv sync and cargo build also rewrote theirs by default. A CI job that runs one of those default commands resolves fresh whenever the manifest has drifted, which is the exposure a lockfile is meant to remove. Poetry and Go refuse drift in every mode.

Treat the lockfile as an artifact, not a cache. Socket's guidance in The Changelog podcast on staying safe on npm is to avoid "deleting or regenerating it unnecessarily." Deleting a lockfile to clear a merge conflict re-resolves every range in the tree against today's registry, and the result usually lands in one large diff that nobody reads.

What a lockfile does not cover

#

A lockfile constrains one path into a project: the strict install that replays it. New code arrives by three other paths, and Figure 1 shows all four.

  1. 1

    Strict install

    npm ci, pnpm install --frozen-lockfile, uv sync --locked, cargo build --locked. Replays recorded entries and checks each hash. A release published after locking cannot enter.

    Lockfile protects
  2. 2

    Resolution

    npm install, uv lock, an update bot's pull request, or deleting the lockfile. Picks from the registry as it is that minute, then writes the answer into the lockfile. Cooldowns and diff review act here.

    Lockfile records, does not vet
  3. 3

    Lockfile edit

    A contributor, bot, or worm changes entries directly. The strict install trusts the new entries. pnpm checks them against the registry by default; npm, Bun, and Yarn outside hardened mode do not.

    Trusted, checks vary
  4. 4

    Outside the lockfile

    npx, global installs, cargo install without --locked, uvx, scaffolders. Resolve fresh on every run and never read the project's lockfile.

    Lockfile not consulted
Figure 1. Four paths by which code reaches a project that has a lockfile. Only path 1 is the lockfile's to protect.

Figure 1 in text. Path 1, strict install: npm ci, pnpm install --frozen-lockfile, uv sync --locked, or cargo build --locked replays the recorded entries and verifies each hash, so a release published after locking cannot enter. This is the only path the lockfile protects. Path 2, resolution: npm install, uv lock, a dependency update pull request, or deleting the lockfile picks versions from the registry as it is at that moment and writes them into the lockfile; a dependency cooldown and review of the lockfile diff act here. Path 3, a lockfile edit: a contributor, bot, or worm changes entries directly, and the strict install trusts the new entries; pnpm checks them against the registry by default, while npm, Bun, and Yarn outside hardened mode do not. Path 4, outside the lockfile: npx, global installs, cargo install without --locked, uvx, and project scaffolders resolve fresh on every run and never read the project's lockfile.

The first gap is what enters. A lockfile freezes the output of resolution, including a malicious version locked during an attack window. Every later strict install fetches it again with a verified hash, for as long as the registry still serves it. A dependency cooldown acts at resolution, which is the right place: pnpm 11 made a minimum release age of 1440 minutes the default, and Rust 1.100, scheduled for November 12, 2026, adds cooldowns to Cargo. In the popular Rust crates compromise in August 2026, the three malicious releases stayed on crates.io for 86, 90, and 107 minutes, according to the Rust Security Response Team. A one-day window covers all three.

Cooldowns and lockfiles interact differently by tool. Cargo's resolver keeps versions already recorded in Cargo.lock even when they are younger than the configured minimum. pnpm goes the other way: by default it re-applies minimumReleaseAge and its trust policy to every lockfile entry on install. Setting trustLockfile to true, which is not the default, skips that pass. The pnpm settings documentation warns that with trustLockfile on, "A poisoned lockfile ... can slip through."

Automated dependency updates are resolution on a schedule. Each update pull request rewrites the lockfile with whatever was newest when the bot ran. Auto-merging those pull requests turns the lockfile back into a moving target with a short delay.

The second gap is what runs outside the lockfile. npx uses a local copy when the project has one; otherwise it resolves the package and its whole tree against the registry on every run. In one case from the axios window that we documented, a CI pipeline ran a CLI through npx, pulled the malicious dependency through a transitive axios range, and produced command-and-control traffic during a build step. A clean project lockfile checked afterwards says nothing about that run. Global installs, cargo install, pipx, and uvx follow the same pattern. Publisher-controlled trees are a related gap: npm 11 and earlier honor an npm-shrinkwrap.json inside a published package, which pins a subtree the package author chose, while npm v12 ignores it.

How does lockfile poisoning work across package managers?

#

Lockfile poisoning is an edit to a lockfile entry that makes a strict install fetch different code under a trusted name and version. The attacker changes the source and the hash together, so the integrity check passes. The manifest is unchanged, so the pull request looks like routine lockfile churn. This is the third gap: the lockfile is trusted, and in three of the five package managers we tested, nothing checks that it is honest.

The delivery channel is already automated. SANDWORM_MODE, an npm worm we analyzed in February 2026, injects dependencies into repositories it can write to and patches package-lock.json, yarn.lock, and pnpm-lock.yaml to match.

We poisoned one entry in each lockfile format. In the JavaScript tests the entry still claims to be ms 2.1.3, but its source points at left-pad 1.3.0, a harmless stand-in for a payload. In the uv test, the idna entry points at a six wheel. Each test ran the strict install from an empty cache:

Package managerEditStrict install resultCheck that fired
npm 12.2.1resolved and integrity point at left-padInstalled left-pad as node_modules/ms. npm ls printed ms@2.1.3. Exit 0.None
Bun 1.4.4Entry under key ms names left-pad@1.3.0 with its hashInstalled left-pad as node_modules/ms and printed + ms@1.3.0. Exit 0.None, although ^2.1.0 cannot match 1.3.0
Yarn 4.18.1, --immutableresolution points at left-pad; checksum deletedInstalled left-pad as node_modules/ms, wrote left-pad's checksum into yarn.lock, exit 0None
Yarn 4.18.1, hardened modeSame editRefused: YN0078 Invalid resolution ms@npm:^2.1.0 → left-pad@npm:1.3.0Re-resolution against the registry
pnpm 12.16.1, --frozen-lockfileintegrity and tarball point at left-padRefused: ERR_PNPM_TARBALL_URL_MISMATCHTarball URL compared with registry metadata, on by default
uv 0.11.32, --lockedidna entry points at a six wheel and its hashRefused to parse uv.lock: wheel version inconsistent with the entryWheel filename compared with the entry

The Yarn result deserves a second look. --immutable is documented to make Yarn "refuse to change the lockfile in any way." When the poisoned entry had no checksum, Yarn fetched left-pad, added its checksum to yarn.lock, and exited 0. Yarn's hardened mode caught the swap by re-resolving every entry against the registry. Yarn enables hardened mode automatically for pull requests from public GitHub repositories, and its documentation warns that it makes installs "significantly slower."

pnpm's design is the right default. A public name and version has exactly one valid artifact, so the registry can arbitrate every entry. Checking a lockfile against registry metadata costs one metadata request per package. pnpm's verification pass already makes that request to enforce its release-age policy, and that pass is what caught our swap. npm, Bun, and Yarn should do the same by default. Until they do, review lockfile diffs as code, run lockfile-lint or Yarn's hardened mode in CI, and use the registry comparison script in our package-lock.json entry for npm.

Lockfile poisoning is the same failure as manifest confusion: metadata and contents disagree, and the tooling believes the metadata. We analyze what is actually in a package, not what its metadata claims, and package managers should treat their own lockfiles the same way.

A lockfile is a record of a decision made once, at resolution time, under whatever registry state existed that minute. Every tool downstream reads it as fact, from npm ls to a software composition analysis scanner. The property that makes it valuable, replaying the same tree forever, is also what makes a bad lockfile durable. Nothing in today's formats records why a version was trusted, so nothing can revisit that decision when the answer changes.