pnpm vs Yarn: Which to Use, and What Changes in CI
On a developer machine pnpm wins on disk and install speed through a shared content-addressable store. On an ephemeral CI runner that store starts empty every run, which changes the comparison more than most write-ups admit.
pnpm and Yarn are the two serious alternatives to npm, and the usual comparison is about local install speed and disk usage. That comparison is real but incomplete, because the mechanism pnpm wins with is a shared global store: every version of every package is stored once and projects reference it by hard link, so ten projects using the same React version keep one copy on disk.
A CI runner is a fresh machine. There is no shared store, no previous project, and nothing to hard-link against, so on a cold CI job the property that makes pnpm fast locally does not exist unless you deliberately cache the store between runs. Understanding that is the difference between pnpm being a large CI win and roughly a wash.
Yarn made a different bet. Yarn Berry introduced Plug and Play, which removes node_modules entirely and resolves imports through a .pnp.cjs file against a global cache. It is genuinely faster and more disk-efficient, and it breaks tooling that assumes node_modules exists, which is the central trade you are accepting.
pnpm vs Yarn at a glance
| pnpm | Yarn | |
|---|---|---|
| Install strategy | Content-addressable store plus hard links | node_modules (classic) or PnP (Berry) |
| Disk usage | One copy per version, machine-wide | Per project (classic), global cache (PnP) |
| node_modules layout | Symlinked, strict by default | Flat (classic), none (PnP) |
| Phantom dependencies | Blocked by default | Possible in classic, blocked under PnP |
| Workspaces | Mature, with --filter and catalogs | Mature, long-established |
| Tooling compatibility | High | High (classic), lower (PnP) |
| CI lockfile behaviour | Frozen automatically in CI | --immutable in Berry |
| Corepack | Supported, standard in Node 26 | Supported, standard in Node 26 |
The strictness difference nobody mentions until it breaks a build
pnpm creates a non-flat node_modules where a package can only import what it actually declares. Yarn classic creates a flat node_modules where anything hoisted to the top is importable whether you declared it or not. Those undeclared imports are phantom dependencies, and they work locally right up until they do not.
- A package that imports a transitive dependency it never declared works under Yarn classic and fails under pnpm.
- That failure is correct: the import was always a latent bug and Yarn hoisting was hiding it.
- It is also the single most common reason a Yarn-to-pnpm migration is more work than expected, so budget for it rather than being surprised.
- Yarn PnP is strict in the same way, so migrating Yarn classic to Yarn PnP surfaces exactly the same class of breakage.
What actually happens on a CI runner
This is the part that changes the recommendation. On a fresh runner the pnpm store is empty, so the hard-link mechanism has nothing to link to and pnpm has to fetch packages like anything else. You get the benefit back by caching the store between runs, which pnpm supports directly.
- pnpm switches to frozen-lockfile mode automatically when it detects CI, so you do not need to pass the flag.
- Since pnpm 11 an install fails outright if the lockfile was written by a newer pnpm major version, rather than silently rewriting it. That is a correctness improvement and also a new way for CI to go red after a colleague upgrades locally.
- pnpm documents that caching the store makes installation faster in most scenarios but is not required and not guaranteed to help, so measure rather than assume.
- name: Install pnpm and Node.js
uses: pnpm/setup@v2.0.0
with:
runtime: node@${{ matrix.node-version }}
cache: true # caches the pnpm store between runs
- run: pnpm install # frozen-lockfile is automatic in CIYarn PnP: what you gain and what you give up
Plug and Play is the most interesting idea in this comparison and the one with the sharpest trade-off. Removing node_modules removes the slowest part of an install, which is writing tens of thousands of small files.
- Gains: faster installs, far less disk, and strict resolution that blocks phantom dependencies.
- Costs: any tool that scans
node_moduleson disk needs PnP awareness. That still catches out bundlers, editors, and debug tooling in the long tail. - The practical consequence is that PnP is a strong fit for a codebase whose tooling you fully control, and a poor fit for one with a wide plugin surface you do not.
Monorepos
Both have mature workspace implementations, and this is where most of the real decision-making happens.
- pnpm
--filterselects packages by name, path, or change relative to a git ref, which maps directly onto "build only what this PR touched". - pnpm catalogs let you declare a dependency version once and reference it from every workspace package, which removes a whole class of version-drift PRs.
- pnpm strictness is more valuable in a monorepo than anywhere else, because accidental cross-package imports are exactly the coupling you are trying to prevent.
- Yarn workspaces are long-established and well understood, and remain a perfectly good choice for an existing Yarn monorepo.
Should you migrate an existing Yarn repo?
- If it is Yarn classic and working, the honest answer is often no. Migration cost is real and the payoff is mostly CI minutes and disk.
- If you are hitting phantom-dependency bugs or version drift across a monorepo, pnpm strictness and catalogs address the actual cause and the migration is worth it.
- If you are starting a new monorepo, pnpm workspaces plus catalogs is the lower-friction path today.
- If you are on Yarn Berry with PnP and it works, stay. You already have strict resolution and disk efficiency; pnpm would be a lateral move.
- Whatever you choose, pin it with Corepack so the runner and every developer machine agree.
Decide with your own numbers, not a feature table
Feature comparisons age badly and rarely decide anything, because both tools in a mature category can do the job. What differs is how each behaves on your repository, and that takes one afternoon to measure.
# time a cold install with each candidate, cache cleared
hyperfine --prepare "rm -rf node_modules" --warmup 1 \
"<tool-a> install" "<tool-b> install"
# and the thing CI actually pays for: a cold run with no local cache
docker run --rm -v "$(pwd):/w" -w /w node:22 sh -c "<tool> install"What actually changes when you switch
- Lockfile format. A switch is a one-way door for anyone still on the old tool until everyone migrates, so plan it as a single coordinated change.
- Resolution strictness. Tools differ on whether an undeclared transitive import works, and the stricter one will surface latent bugs as new failures.
- CI cache configuration. The cache path and key differ per tool; carrying over the old ones silently disables caching.
- Everyone on the team and every runner must move together. Pin the version so they cannot drift.
The verdict
New project or new monorepo: pnpm. The strict resolution catches real bugs, --filter and catalogs are the best workspace ergonomics available, and the disk saving on a developer machine with many projects is substantial.
Existing Yarn classic repo that works: the migration is not free, and the main wins are CI minutes and disk rather than correctness, unless phantom dependencies are actively biting you.
Existing Yarn Berry with PnP: stay. You already have the strictness and the disk efficiency, and moving would be sideways.
In all cases, remember that the store advantage does not exist on a cold runner. Cache the store, pin the package manager with Corepack, and measure your own install time before assuming a change in package manager will move it.
Frequently asked questions
Is pnpm faster than Yarn?
Why does pnpm save disk space?
Why does my code break when I switch from Yarn to pnpm?
node_modules, so a package can import something it never declared. pnpm creates a strict non-flat layout where that import fails. The failure is correct; the fix is to declare the missing dependency rather than to loosen resolution.Do I need --frozen-lockfile with pnpm in CI?
Should I cache the pnpm store in GitHub Actions?
pnpm/setup action does it with cache: true. pnpm documents that this makes installation faster in most scenarios but is not required and not guaranteed to help, so measure your own install times both ways.Is Yarn PnP worth using?
node_modules gives faster installs, much less disk, and strict resolution. The cost is that anything expecting node_modules on disk needs PnP awareness, which still trips up parts of the long tail. Yarn nodeLinker: node-modules is the middle path.pnpm or Yarn for a monorepo?
--filter selects packages by change relative to a git ref, catalogs centralise dependency versions across packages, and strict resolution prevents accidental cross-package imports. An existing Yarn workspace setup that works is not a problem that needs solving.How do I make sure CI uses the same package manager version as my machine?
packageManager field of package.json. Version skew between a developer machine and the runner is the cause of most lockfile-related CI failures, and pinning removes the category.