# 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

Source: https://socket.dev/glossary/lockfile

Last updated: 2026-11-03

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](https://socket.dev/blog/axios-npm-package-compromised) 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](https://socket.dev/blog/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](https://socket.dev/glossary/semantic-versioning-semver) 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](https://socket.dev/glossary/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](https://go.dev/ref/mod) 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:

```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](https://socket.dev/glossary/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) | Lockfile | What the hash covers | Strict install | Default install when the manifest drifts |
| --- | --- | --- | --- | --- |
| npm 12.2.1 | package-lock.json | Registry tarball, SHA-512 | npm ci | Rewrites the lockfile, even with CI=true |
| pnpm 12.16.1 | pnpm-lock.yaml | Registry tarball, SHA-512 | pnpm install --frozen-lockfile | Rewrites it locally; fails in CI |
| Yarn 4.18.1 | yarn.lock | Yarn's cache zip, SHA-512 | yarn install --immutable | Rewrites it locally; fails in CI |
| Bun 1.4.4 | bun.lock | Registry tarball, SHA-512 | bun install --frozen-lockfile | Rewrites the lockfile, even with CI=true |
| uv 0.11.32 | uv.lock | Each sdist and wheel, SHA-256, plus upload time | uv sync --locked | Rewrites the lockfile |
| Poetry 2.4.3 | poetry.lock | Each sdist and wheel, SHA-256 | poetry sync | Fails |
| pip 26.2.1 | pylock.toml (experimental) | Each file, SHA-256 | pip install -r pylock.toml | Not applicable: pip only reads a lockfile you generate |
| Cargo 1.97.0 | Cargo.lock | .crate archive, SHA-256 | cargo build --locked | Rewrites the lockfile |
| Go | None. go.mod selects, go.sum verifies | Module tree and go.mod, SHA-256 | Default behavior | Fails, since Go 1.16 |
| Bundler | Gemfile.lock | .gem file, SHA-256, in new lockfiles | BUNDLE\_FROZEN=true | Rewrites the lockfile unless frozen is set |

Python gained a standard format only recently. [Python's adoption of a standard lock file format](https://socket.dev/blog/python-adopts-standard-lock-file-format-for-reproducible-installs), 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.

| Approach | What it pins | What it stops | What it does not cover | Use it for |
| --- | --- | --- | --- | --- |
| Wildcard or open range: \*, "", \>1 | Nothing | Nothing | Every future release, including new major versions | Never |
| Caret or tilde range: ^4.3.0, ~4.3.0 | The major or minor line | Breaking changes, if the maintainer follows semver | Malicious or buggy releases inside the range | Libraries you publish |
| Exact pin in the manifest: 4.4.3 | Direct dependencies only | Silent upgrades of direct dependencies; makes each bump a reviewable diff | Transitive dependencies; duplicate copies for downstream users of a library | Applications, and CLIs published to a registry whose lockfile never reaches users |
| Lockfile plus a strict install | The whole tree | Newly published versions entering an existing install | New dependencies, npx and global tools, poisoned entries, versions that were bad when locked | Every 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](https://socket.dev/alerts/floatingDependency) 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](https://doc.rust-lang.org/cargo/commands/cargo-install.html) 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](https://blog.rust-lang.org/2023/08/29/committing-lockfiles/): "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](https://socket.dev/blog/introducing-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](https://pnpm.io/cli/install) 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:

```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](https://socket.dev/blog/the-changelog-podcast-practical-steps-to-stay-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.

> Figure 1. Four paths by which code reaches a project that has a lockfile. Only path 1 is the lockfile's to protect.
>
> Four paths by which code reaches a project that has a lockfile. Only the strict install path is protected by the lockfile.

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](https://socket.dev/glossary/dependency-cooldown) acts at resolution, which is the right place: [pnpm 11 made a minimum release age of 1440 minutes the default](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults), and [Rust 1.100, scheduled for November 12, 2026, adds cooldowns to Cargo](https://socket.dev/blog/rust-cargo-cooldowns). In [the popular Rust crates compromise](https://socket.dev/blog/popular-rust-crates-compromised) 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](https://pnpm.io/settings/dependency-resolution) warns that with `trustLockfile` on, "A poisoned lockfile ... can slip through."

[Automated dependency updates](https://socket.dev/glossary/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](https://socket.dev/glossary/npx-npm-package-runner) 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](https://socket.dev/glossary/npm-shrinkwrap-json) inside a published package, which pins a subtree the package author chose, while [npm v12](https://socket.dev/blog/npm-12) 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](https://socket.dev/blog/sandworm-mode-npm-worm-ai-toolchain-poisoning), 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 manager | Edit | Strict install result | Check that fired |
| --- | --- | --- | --- |
| npm 12.2.1 | resolved and integrity point at left-pad | Installed left-pad as node\_modules/ms. npm ls printed ms@2.1.3. Exit 0. | None |
| Bun 1.4.4 | Entry under key ms names left-pad@1.3.0 with its hash | Installed 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, --immutable | resolution points at left-pad; checksum deleted | Installed left-pad as node\_modules/ms, wrote left-pad's checksum into yarn.lock, exit 0 | None |
| Yarn 4.18.1, hardened mode | Same edit | Refused: YN0078 Invalid resolution ms@npm:^2.1.0 → left-pad@npm:1.3.0 | Re-resolution against the registry |
| pnpm 12.16.1, --frozen-lockfile | integrity and tarball point at left-pad | Refused: ERR\_PNPM\_TARBALL\_URL\_MISMATCH | Tarball URL compared with registry metadata, on by default |
| uv 0.11.32, --locked | idna entry points at a six wheel and its hash | Refused to parse uv.lock: wheel version inconsistent with the entry | Wheel 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](https://yarnpkg.com/features/security) 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](https://github.com/lirantal/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](https://socket.dev/glossary/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.
