Run npm install on a project, then pnpm install against the same package.json, and you get two different node_modules trees and two different lockfiles. Add a teammate who reaches for Yarn and the repo can end up with three lockfiles in its history, each describing a slightly different dependency tree. All three tools read the same package.json and pull from the same npm registry. They disagree about what to put on disk.

How npm, Yarn, and pnpm lay out node_modules

npm’s layout is flat. Every package your dependencies need, however deep in the tree, gets hoisted to the top-level node_modules whenever there’s no version conflict. That has been the default since npm 3. The side effect: your code can require() or import a package you never listed in package.json, as long as something else in the tree depends on it. It works until that other package drops the dependency in a minor release and your build breaks with no change to your own code. That’s a phantom dependency, and npm’s layout does nothing to stop it.

Yarn Classic (v1) hoists the same way, so it has the same exposure. Yarn Berry (v2 and later) defaults to Plug’n’Play, which has no node_modules at all. Dependencies stay zipped in .yarn/cache, and a generated .pnp.cjs file maps each import straight to the right archive. Commit .yarn/cache alongside the lockfile and a fresh clone runs without an install step; Yarn calls this zero-install. The cost is compatibility. Tools that scan node_modules directly, and native addons that expect real files on disk, can fail under PnP. Setting nodeLinker: node-modules in .yarnrc.yml brings back a conventional flat layout and gives up zero-install.

pnpm keeps one content-addressable store per machine. Every package version lives on disk once, keyed by content hash, no matter how many projects use it. For each project, pnpm hard-links the needed files into a virtual store at node_modules/.pnpm, then symlinks only your direct dependencies into the top-level node_modules. You can resolve node_modules/react because you declared it. You cannot resolve a package that only exists two levels down in another dependency’s tree, because nothing links it to the top. The phantom-dependency problem is closed by the structure itself, not by discipline.

npm, pnpm, and Yarn commands side by side

Day to day, the commands map almost one to one. The difference that matters is the CI flag: install exactly what the lockfile says, resolve nothing, write nothing.

ActionnpmpnpmYarn Berry
Install everythingnpm installpnpm installyarn install
Add a dependencynpm install axiospnpm add axiosyarn add axios
Add a dev dependencynpm install -D vitestpnpm add -D vitestyarn add -D vitest
Run a scriptnpm run buildpnpm run buildyarn run build
CI-safe installnpm cipnpm install --frozen-lockfileyarn install --immutable

npm ci deletes node_modules first and installs strictly from package-lock.json, failing if the lockfile and package.json disagree. --frozen-lockfile and --immutable skip the delete but enforce the same rule: no new resolutions, no lockfile writes, a non-zero exit if the lockfile can’t be satisfied as-is. Use the CI-safe form in every pipeline and the plain install on your own machine. A stale lockfile should fail the build loudly, not get silently resolved around.

If you install dependencies inside a Docker image, Dockerfile Best Practices covers copying the manifest and lockfile and running the install before COPY . ., so that layer only rebuilds when the lockfile changes.

Why a repo should commit only one lockfile

npm writes package-lock.json, pnpm writes pnpm-lock.yaml, and both Yarn generations write yarn.lock (in formats that differ between v1 and Berry). Each records the exact version of every transitive dependency. None of them reads another’s.

The breakage looks like this. Someone runs yarn add lodash in a repo that has only ever committed package-lock.json. Now yarn.lock exists too, the two files disagree about what’s resolved, and whichever tool CI happens to run is the one in effect. The next local install with the other tool resolves something different again.

Pick one package manager per repository, commit only its lockfile, and ignore the others before anyone generates them by accident:

1
2
3
# if this repo uses npm
yarn.lock
pnpm-lock.yaml

How to pin the package manager with corepack

A .gitignore doesn’t stop someone from running the wrong tool if it’s on their PATH. corepack does. It reads the packageManager field in package.json and runs that exact version of npm, Yarn, or pnpm, downloading it on first use:

1
2
3
{
  "packageManager": "[email protected]"
}

Run corepack enable once to put Yarn’s and pnpm’s shims on your PATH (npm’s shim is only installed if you ask for it). From then on, pnpm install in this repo runs the pinned 10.12.1 on every machine, and nobody has to remember to check.

Corepack ships bundled with Node from 14.19.0 up to, but not including, 25.0.0. On Node 25 and later, install it with npm install -g corepack, then run corepack enable as before. In CI, the cache option of actions/setup-node ('npm', 'yarn', or 'pnpm') works alongside corepack; How to Cache Dependencies in GitHub Actions explains the cache keys behind it.

How workspaces differ in npm, Yarn, and pnpm

All three handle a monorepo of linked local packages. They differ in where you declare the members and how you target one.

npm and Yarn read a workspaces array from the root package.json:

1
2
3
4
5
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*"]
}

Install once at the root and the local packages get linked into each other’s node_modules. To run a script in one package, npm takes a path:

1
npm run build --workspace=packages/api

Yarn takes the package name:

1
yarn workspace api run build

pnpm ignores the workspaces field and reads membership from its own pnpm-workspace.yaml:

1
2
packages:
  - "packages/*"

It targets packages by name with --filter:

1
pnpm --filter api build

Migrating an npm or Yarn monorepo to pnpm means writing that pnpm-workspace.yaml yourself from the existing workspaces globs. pnpm import can convert the old lockfile so the first install keeps the versions you already had.

When pnpm’s strict layout breaks a dependency

The structure that blocks phantom dependencies in your code also exposes packages that relied on one. A package that imports lodash without listing it in its own dependencies works under npm, because lodash sits at the top level regardless of who put it there. Under pnpm the same require('lodash') throws Cannot find module.

The real fix belongs in that package’s package.json, so open an issue or a patch upstream. While you wait, pnpm has escape hatches. From pnpm 10.6 onward they live in pnpm-workspace.yaml; on an older pinned version, set the same keys, hyphenated, in .npmrc instead (public-hoist-pattern, shamefully-hoist, node-linker). publicHoistPattern hoists only the packages matching a glob to the root, which is what you want for one or two offenders:

1
2
publicHoistPattern:
  - "lodash"

shamefullyHoist: true hoists everything, close to npm’s default layout. Use it only when several unrelated packages assume a flat tree, because it gives up the protection pnpm exists for. nodeLinker: hoisted goes further and drops the symlinks entirely, for tools that break on the symlinks rather than on the missing hoist. Yarn Berry has the same class of problem under PnP, and its answer is nodeLinker: node-modules in .yarnrc.yml.

Which package manager to use for a new project

For a new project, use pnpm. One copy of each package version per machine keeps installs small on disk, and the strict layout surfaces a missing dependency on your laptop instead of in a broken release months later. Expect to add a publicHoistPattern entry or two in the first week.

If you inherit an npm project that installs cleanly and nothing is forcing a change, stay on npm. Add a packageManager field, run corepack enable, and every machine will at least run the same npm version.

Choose Yarn Berry when you want zero-install with the cache committed to git. Without that, PnP’s compatibility cost buys you nothing.