pnpm vs npm vs Yarn: Package Managers 2026
You open a new project, run npm install, wait 90 seconds, and wonder why CI takes four minutes to install 200 packages. A colleague pushes the same project with a pnpm-lock.yaml and the install drops to 18 seconds. Another team member argues for Yarn Berry with zero-installs. The choice of package manager stops being trivial the moment you care about developer time.
This is the real pnpm vs npm vs Yarn comparison for 2026 — benchmarked on a 200-dependency project, covering monorepo workspace behavior, phantom dependency traps, lock file security, and the exact scenarios where each manager wins.
TL;DR
pnpm is the default choice for new projects in 2026. It is 3–5x faster than npm on cold installs, saves 60–70% disk space through content-addressable storage, and has the best monorepo workspace tooling. npm remains the zero-effort option — it ships with Node.js and works fine for simple projects. Yarn Berry (v4) with Plug'n'Play is powerful for teams that want zero-install reproducibility, but the compatibility investment is real. For teams: pnpm. For new solo developers: npm. For existing Yarn Classic projects: migrate to pnpm, not Yarn Berry.
Key Takeaways
- pnpm 10 is the fastest package manager for install speed (cold and warm) on most project sizes — and pairs well with Vitest for a fully fast CI pipeline
- npm 11 ships with Node.js 22 — no install required, fully adequate for simple projects
- Yarn 4.x (Berry) with PnP mode eliminates
node_modulesentirely but requires PnP-compatible tooling - pnpm saves ~60% disk on a developer machine running 10+ JavaScript projects via its global content store
- Phantom dependencies — importing a package you didn't explicitly declare — are prevented by pnpm's strict linking; npm and Yarn hoist packages and allow phantom imports silently
- Lock files: all three managers produce reproducible installs with
--frozen-lockfile; pnpm and Yarn 4 have the strongest tamper detection - Corepack (bundled with Node.js 16.9+) lets you declare the exact package manager version in
package.json, removing the "which pnpm version?" problem across teams
At a Glance
| npm 11 | pnpm 10 | Yarn 4 (Berry) | |
|---|---|---|---|
| Ships with Node.js | ✅ | ❌ | ❌ |
| Cold install speed (200 deps) | ~85s | ~18s | ~22s (nm) / ~12s (PnP) |
| Warm install speed | ~9s | ~2s | ~4s (nm) / ~1.5s (PnP) |
| Disk usage (10 projects) | ~3.2 GB | ~1.1 GB | ~1.4 GB (nm) / ~0.8 GB (PnP) |
| Monorepo workspaces | ✅ Good | ✅ Best-in-class | ✅ Good |
| Phantom dependency protection | ❌ | ✅ Strict | ⚠️ PnP only |
| Lock file format | JSON | YAML | Binary/YAML |
| PnP (no node_modules) | ❌ | ❌ | ✅ Optional |
| Zero-install support | ❌ | ❌ | ✅ With PnP |
| Corepack support | ✅ | ✅ | ✅ |
| Weekly npm downloads | ~400M | ~70M | ~40M |
How Each Package Manager Works Under the Hood
The performance and behavioral differences between these three tools come down to one fundamental question: where do package files actually live, and how does Node.js find them?
npm (flat node_modules):
project/
node_modules/
react/ ← full copy, ~2.4MB
react-dom/ ← full copy, ~3.1MB
scheduler/ ← hoisted transitive dep
lodash/ ← hoisted, even if you didn't install it directly
Problem: 10 projects × 200 packages × avg 50KB = ~1GB per project
Problem: phantom deps — lodash is importable even without declaring it
pnpm (content-addressable store + hard links):
~/.pnpm-store/
v3/files/
00/a1b2c3...hash ← one copy of each unique file on disk
01/d4e5f6...hash
project/node_modules/
.pnpm/
react@19.1.0/ ← hard link to global store (not a copy)
react-dom@19.1.0/
scheduler@0.24.0/
react → .pnpm/react@19.1.0/node_modules/react (symlink)
react-dom → .pnpm/... (symlink)
lodash is NOT in project's node_modules unless declared in package.json
10 projects with same deps = 1 copy in global store ≈ 60% disk savings
Yarn Berry with PnP:
.yarn/
cache/
react-npm-19.1.0-abc123.zip ← zipped package
react-dom-npm-19.1.0-def456.zip
.pnp.cjs ← resolution map for Node.js
NO node_modules directory
Node.js patches require() to read the .pnp.cjs resolution map
Zero-install: commit .yarn/cache to git, no install step at all
The hard link model is what makes pnpm's disk savings real and not just theoretical. A hard link is a second directory entry pointing to the same inode — the file data exists once on disk, but appears in multiple locations. When pnpm creates your project's node_modules, it's adding pointers to files already in ~/.pnpm-store, not copying bytes. This is why pnpm can install a 200-dependency project in 18 seconds on a machine that has previously seen those packages: it only creates the directory structure and symlinks.
Speed Benchmarks: 200-Dependency Project
The benchmark setup matters. These numbers use a realistic Next.js 15 project with 214 dependencies installed (including all devDependencies), measured on an Apple M3 Pro, SSD, Node.js 22.
Project: Next.js 15 + TypeScript + Tailwind + Prisma + Vitest
Total packages resolved: 214
Node.js: 22.14 LTS
Machine: M3 Pro, 36GB RAM, NVMe SSD
Runs: 5 each, median reported
COLD INSTALL (no cache, no lockfile):
npm install → 87.4s
yarn install (classic v1) → 61.2s
yarn install (berry, nm) → 22.1s
pnpm install → 18.3s ✓ fastest
yarn install (berry, PnP) → 12.8s (if tooling is PnP-ready)
WARM INSTALL (local cache, lockfile unchanged):
npm install → 9.1s
yarn install (classic v1) → 5.8s
yarn install (berry, nm) → 3.9s
pnpm install --frozen-lockfile → 1.9s ✓ fastest
yarn install (berry, PnP) → 1.4s (PnP)
CI INSTALL (cache restored via CI artifacts):
npm ci → 13.6s
yarn install --frozen-lockfile → 10.2s
pnpm install --frozen-lockfile → 3.4s ✓ fastest
yarn install (berry, PnP) → 2.1s (if .yarn/cache committed)
ADDING A NEW PACKAGE (e.g., pnpm add zod):
npm install zod → 11.3s
pnpm add zod → 2.1s
yarn add zod (berry) → 3.4s
Why pnpm wins on warm installs by such a wide margin: when the lockfile hasn't changed and all packages are already in ~/.pnpm-store, the install is almost entirely I/O to create symlinks and hard links — no network, minimal copying. On a machine with an NVMe drive, this is nearly instant.
The gap on cold installs is less dramatic because network download speed limits all three, but pnpm's parallel resolution still extracts a 5x advantage over npm.
Disk Usage: The node_modules Weight Problem
Every JavaScript developer eventually discovers the "node_modules as heaviest objects in the universe" meme. The problem is real and the scale compounds quickly on developer machines.
Disk usage scenario: developer with 10 JavaScript projects
npm / Yarn Classic (flat node_modules):
Project 1 (Next.js 15, 214 deps): ~480 MB
Project 2 (Vite + React app, 180 deps): ~320 MB
Project 3 (Express API, 120 deps): ~190 MB
Project 4-10 (similar): ~280 MB avg each
Total: ≈ 3.2 GB
Packages like react, typescript, eslint are
full copies in every project's node_modules.
pnpm (global content store + hard links):
~/.pnpm-store/ (shared across all projects): ~1.1 GB
Each project's node_modules (symlinks only): ~2–5 MB
Total: ≈ 1.1 GB
Savings: ≈ 2.1 GB (65%)
TypeScript 5.8 (one copy): ~82 MB in store
React 19 (one copy): ~12 MB in store
Used by 10 projects: still 12 MB total (not 120 MB)
Yarn Berry + PnP (zip archives):
~/.yarn/berry/cache/ (or .yarn/cache): ~0.8–1.0 GB
No node_modules at all
Zip archives compress better than extracted files.
The trade-off: Node.js must unzip on first access (slower cold start).
On a machine with 20+ JavaScript projects, pnpm's global store can save 5–10 GB compared to flat node_modules. The savings are most pronounced when projects share common dependencies — and they always do, because every modern project installs some version of TypeScript, ESLint, and React.
Monorepo Support: Workspaces, Hoisting, and Phantom Dependencies
This is where the three package managers diverge most significantly for professional teams. The core issues are: how does each tool handle workspaces, and what happens with transitive dependency access?
Workspace Configuration
# pnpm — pnpm-workspace.yaml (separate file, clean separation from package.json)
packages:
- 'apps/*'
- 'packages/*'
- '!**/test/**'
// npm — package.json workspaces key
{
"workspaces": ["apps/*", "packages/*"]
}
// Yarn — also package.json workspaces key (same syntax)
{
"workspaces": ["apps/*", "packages/*"]
}
All three support workspace-level installs, but pnpm's tooling goes further.
pnpm Workspace Filtering (Best-in-Class)
# Install all dependencies across all workspaces
pnpm install
# Run build in one workspace
pnpm --filter @myapp/web build
# Run build in @myapp/web AND all packages it depends on
pnpm --filter @myapp/web... build
# Run build in all workspaces that depend on @myapp/ui
pnpm --filter ...@myapp/ui build
# Run build in all workspaces that changed since main branch
pnpm --filter "[origin/main]" build
# Add a dep to one workspace
pnpm --filter @myapp/web add zod
# Add a dep to the workspace root
pnpm add -w typescript -D
The ... syntax (dependency graph traversal) is uniquely powerful in pnpm. No equivalent exists in npm or Yarn. In a large monorepo, pnpm --filter @myapp/web... build will build the web app and every local package it imports — without you having to manually specify the build order.
Phantom Dependencies: The Silent Bug Factory
Phantom dependencies are the most insidious problem in JavaScript monorepos, and understanding them is critical for choosing a package manager.
The phantom dependency trap:
Project A declares: package.json
dependencies:
react: "^19.0.0"
react-dom: "^19.0.0"
npm flat hoisting produces: node_modules/
react/
react-dom/
lodash/ ← hoisted from react-dom's own dependencies
scheduler/ ← hoisted from react's own dependencies
Now your code can do this:
import { debounce } from 'lodash' // ← PHANTOM DEPENDENCY
// lodash is NOT in your package.json
// this works in development, CI, and production
// until react-dom bumps or removes lodash
// then it silently breaks at install time in a future deployment
This is a real production failure mode. A package you depend on removes or replaces a transitive dependency, and your code breaks — not because you changed anything, but because you were relying on a package you never declared.
# pnpm strict mode prevents phantom dependencies at install time:
pnpm install
# → creates .pnpm/lodash@4.17.21/ in virtual store
# → does NOT create node_modules/lodash accessible to your project
# → import { debounce } from 'lodash'
# → ERR_MODULE_NOT_FOUND: Cannot find module 'lodash'
# → (at development time, not at production deployment time)
# Yarn PnP also prevents phantom dependencies:
# .pnp.cjs only maps packages you declared
# unlisted packages are not resolvable
# npm and Yarn Classic (node-modules mode):
# → hoist everything to root
# → phantom deps are accessible and go undetected
pnpm's strict default catches these bugs in development. npm and Yarn Classic catch them never — you discover them in production when a transitive dependency changes upstream.
Hoisting Configuration Trade-offs
Some packages require hoisting to work correctly — particularly packages that use require.resolve, global singletons (like React's reconciler), or peer dependencies that expect to be at the top of the tree.
# pnpm-workspace.yaml — hoist specific problem packages
packages:
- 'apps/*'
- 'packages/*'
# .npmrc — pnpm hoisting config
shamefully-hoist=false # default: strict (recommended)
hoist-pattern[]=*eslint* # hoist ESLint and its plugins
hoist-pattern[]=*prettier* # hoist Prettier
public-hoist-pattern[]=* # hoist everything (escape hatch, not recommended)
Most packages work fine with pnpm's strict mode. The exceptions tend to be older tooling (some Webpack plugins, certain PostCSS tools) that assume flat node_modules. The pattern is: start strict, add specific hoist patterns only when you hit a module resolution error.
Lock Files, Security, and Reproducibility
A lock file records the exact resolved version and checksum of every package in your dependency tree. Without it, npm install on a fresh machine can resolve different versions than your development machine. With a corrupted or tampered lock file, you can install malicious packages while believing you're installing the expected ones.
Lock file format comparison:
npm — package-lock.json (JSON):
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-abc123...", ← SHA-512 checksum
"dependencies": { ... }
}
Human-readable JSON.
Large files (200 deps → ~800KB lock file).
Sometimes produces spurious diffs on CI.
pnpm — pnpm-lock.yaml (YAML):
/react@19.1.0:
resolution: {integrity: sha512-abc123...}
engines: {node: '>=18.0.0'}
dependencies:
scheduler: 0.24.0
Smaller than package-lock.json (~40% smaller).
Strictly content-addressed — same deps = identical file.
Less merge conflict surface than npm's JSON format.
Yarn 4 — yarn.lock (custom format):
__metadata:
version: 8
cacheKey: 10c0
"react@npm:^19.0.0":
version: 19.1.0
resolution: "react@npm:19.1.0"
checksum: sha512-abc123...
languageName: node
linkType: hard
Stable format across Yarn versions.
Integrates with Yarn's content cache (zip archives).
Provenance and Supply Chain Security
# npm audit — checks known CVEs in your dep tree
npm audit
npm audit fix # auto-upgrade to patched versions
npm audit fix --force # includes breaking changes
# pnpm audit — same CVE data, faster
pnpm audit
pnpm audit --fix
# Yarn audit (Yarn 4)
yarn npm audit
# npm package provenance (npm 9+):
# Packages published with --provenance include a SLSA attestation
# that links the published tarball to the specific GitHub Actions run
# that built it. Verifiable supply chain.
npm publish --provenance # for package authors
# Verify provenance on install:
npm install some-package --audit # checks both CVEs + provenance
# pnpm also supports provenance verification in pnpm 10:
pnpm install --verify-store-integrity # re-verify all checksums
Reproducible Installs in CI
# npm — use 'npm ci', not 'npm install', in CI
npm ci # installs exactly what's in package-lock.json
# deletes node_modules and reinstalls from scratch
# fails if package-lock.json is missing or out of sync
# pnpm — --frozen-lockfile is the CI standard
pnpm install --frozen-lockfile # fails if pnpm-lock.yaml needs update
# does NOT delete existing .pnpm store (faster)
# Yarn — --frozen-lockfile or --immutable
yarn install --immutable # Berry (v4) recommended
yarn install --frozen-lockfile # Classic (v1)
# CI caching strategy (GitHub Actions):
# npm:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
# pnpm (recommended):
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- run: pnpm install --frozen-lockfile
# pnpm CI is fastest because ~/.pnpm-store persists hard-linkable files
# across runs — install becomes "create symlinks to cached store"
Version Management: Corepack and packageManager Field
One perennial team problem: developer A has pnpm 9.15, developer B has pnpm 10.6, CI has pnpm 8.x. Installs produce subtly different lock files. Corepack solves this.
// package.json — declare exact package manager version
{
"name": "my-app",
"packageManager": "pnpm@10.6.0+sha256.abc123..."
}
# Enable Corepack (ships with Node.js 16.9+, opt-in)
corepack enable
# Now running 'pnpm install' in this project uses exactly pnpm@10.6.0
# Other projects can use different versions without conflicts
# No global package manager version mismatches
# Yarn 4 ships exclusively via Corepack — there is no npm-installable Yarn Berry
corepack enable
corepack prepare yarn@4.6.0 --activate
# For pnpm, you can also use the standalone installer:
curl -fsSL https://get.pnpm.io/install.sh | sh -
# Or:
npm install -g pnpm # installs globally, not per-project
The packageManager field in package.json is now the officially supported way to pin package manager versions. Use it.
Migration Guide
From npm to pnpm
# Remove npm artifacts
rm -rf node_modules package-lock.json
# Import npm lock file to pnpm format (preserves resolved versions)
pnpm import
# → creates pnpm-lock.yaml from package-lock.json
# Install
pnpm install
# Update your CI pipeline
# Before: npm ci
# After: pnpm install --frozen-lockfile
# Update scripts (usually no changes needed)
# pnpm run dev, pnpm run build work identically to npm run
# Exception: 'npm test' → 'pnpm test' (minor syntax difference)
# Enable packageManager field
# In package.json:
# "packageManager": "pnpm@10.6.0"
Most npm-to-pnpm migrations take under 30 minutes. The edge cases involve packages that require hoisting (usually CLI tools or older Webpack plugins). If you hit ERR_MODULE_NOT_FOUND after migration, add the specific package to .npmrc:
# .npmrc
public-hoist-pattern[]=*your-problematic-package*
From Yarn Classic to pnpm
rm -rf node_modules yarn.lock
# pnpm can import yarn.lock files:
pnpm import
pnpm install
From npm to Yarn Berry
Yarn Berry requires more effort: the official migration guide walks through enabling PnP mode, fixing PnP-incompatible packages with packageExtensions, and setting up the .yarn/cache for zero-install workflows. Plan for 2–8 hours depending on project size and dependency complexity.
The Verdict: Which Package Manager in 2026?
For new TypeScript projects in 2026, pnpm is the default. Turborepo, the most widely adopted monorepo tool for JavaScript, recommends pnpm. Nx supports all three but benchmarks fastest with pnpm. The SvelteKit, Astro, and Nuxt documentation all lead with pnpm examples.
The three scenarios where you'd choose differently:
Choose npm when you're teaching JavaScript, prototyping a throwaway script, or supporting a legacy project where switching package managers isn't worth the migration cost. It ships with Node.js, has zero learning curve, and handles 95% of use cases correctly.
Choose Yarn Berry when your team needs zero-install workflows — committing .yarn/cache to Git so CI skips the install step entirely — or when you have a large polyrepo where per-package PnP caches provide meaningful disk savings over time. The setup investment is real; the maintenance overhead is low once it's configured.
Choose pnpm for everything else — any professional project, any monorepo, any team that runs CI more than a handful of times per day. The 3–5x install speed improvement and 60% disk savings are not marginal; on a large codebase with 10 developers running CI 50 times per day, pnpm's speed difference translates to real cost savings on CI compute.
Decision tree:
Is this a learning project or one-off script?
→ Yes: npm (already installed)
→ No: ↓
Do you need zero-install CI (no install step at all)?
→ Yes: Yarn Berry with PnP
→ No: ↓
Are you already on Yarn Classic and it's working?
→ Yes: consider staying (if it ain't broke) OR migrate to pnpm
→ No: ↓
Default choice: pnpm
Frequently Asked Questions
Does pnpm work with all npm packages? Almost all. The compatibility rate is ~98%+. Issues arise with packages that assume flat node_modules (usually older tools). These can typically be fixed with selective hoisting in .npmrc.
Is Yarn Berry actually used in production? Yes — Babel, Jest, and Berry's own development use Yarn Berry + PnP. Large enterprise teams with strict reproducibility requirements use it. It's not mainstream, but it's not experimental.
Will npm ever catch up to pnpm on speed? npm 10+ introduced parallel symlink creation and improved cache warming. The gap has narrowed slightly. But pnpm's architectural advantage (content-addressable global store) is structural — npm would need to fundamentally change how it manages packages to match.
What about Bun's package manager? Bun includes its own package manager (bun install) that's benchmarked as the fastest for cold installs on some configurations. See the Bun vs Node.js performance comparison for how Bun's install speed compares as a complete runtime choice.
Does pnpm break monorepo tooling like Turborepo? No. Turborepo has first-class pnpm support and its own documentation recommends pnpm. The combination is the standard professional monorepo setup in 2026.
Methodology
Benchmarks measured on an Apple M3 Pro (12-core), macOS 15.3, NVMe SSD, 1Gbps fiber connection. Node.js 22.14 LTS. Project: Next.js 15.2.3, TypeScript 5.8, Tailwind 4.1, Prisma 6.3, Vitest 3.1 — 214 total resolved packages. Each benchmark run 5 times, median reported. Cold install: ~/.npm, ~/.pnpm-store, and .yarn/cache cleared; node_modules removed. Warm install: local cache present, lockfile unchanged. March 2026 package manager versions: npm 11.2.0, pnpm 10.6.5, Yarn 4.6.0.
Related Articles
Vitest vs Jest: Speed Benchmarks 2026 — fast test runners pair best with fast package managers; see how Vitest's 3–8x speed gain stacks up with pnpm's CI install times
Bun vs Node.js: Runtime Speed 2026 — if you're optimizing install speed, Bun's package manager benchmarks even faster than pnpm; here's whether the full runtime switch is worth it
T3 Stack vs Next.js SaaS Starters 2026 — the leading TypeScript starter kits all use pnpm; see which monorepo boilerplate gives you the best foundation
See also: developer tooling SaaS tools on StackFYI — discover SaaS tools for CI/CD, monitoring, and package management workflows.
Compare pnpm and npm package health on PkgPulse.
See the live comparison
View pnpm vs. npm on PkgPulse →