cheat sheet
pnpm Package Manager
Fast, disk-efficient package manager using a content-addressable store and hard-links. Covers install, add, remove, scripts, dlx, exec, the pnpm store, and workspaces.
pnpm Package Manager
What it is
pnpm is a fast, disk-space-efficient package manager for Node.js projects. Instead of copying package files into each project's node_modules, pnpm stores every version of every package once in a global content-addressable store on disk, then hard-links (or reflinks) those files into each project. The result is 2–3× faster installs than npm on a cold cache and near-instant installs on a warm one, plus dramatically lower disk usage across many projects.
Key design choices:
- Content-addressable store — each file is stored once, keyed by its content hash.
- Hard-linked
node_modules— projectnode_modulescontain hard links to store files; no data is copied. - Strict by default — packages can only access their declared dependencies, not arbitrarily hoisted siblings.
Install pnpm
# Recommended: via Corepack (ships with Node.js 16.9+)
corepack enable
corepack prepare pnpm@latest --activate
# Standalone installer (macOS / Linux)
curl -fsSL https://get.pnpm.io/install.sh | sh -
# Standalone installer (Windows PowerShell)
iwr https://get.pnpm.io/install.ps1 -useb | iex
# Via npm (works anywhere npm is present)
npm install -g pnpm
Output: (none — exits 0 on success)
Verify the installation:
pnpm --version
Output:
9.12.1
Install project dependencies
pnpm install # install all deps from package.json + pnpm-lock.yaml
pnpm install --frozen-lockfile # CI mode: fail if lockfile would change
pnpm install --prod # skip devDependencies
Output:
Packages: +312
++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 312, reused 308, downloaded 4, added 312, done
Add packages
pnpm add express # add to dependencies
pnpm add -D typescript ts-node # add to devDependencies
pnpm add -O nodemailer # add to optionalDependencies
pnpm add -g serve # install globally
pnpm add lodash@4.17.21 # specific version
pnpm add "axios@>=1.0.0 <2" # version range
pnpm add github:user/repo # from GitHub
pnpm add file:../my-local-pkg # from local path
Output (pnpm add express):
Packages: +57
++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 57, reused 55, downloaded 2, added 57, done
dependencies:
+ express 4.19.2
Remove packages
pnpm remove express # remove from dependencies
pnpm remove -D typescript # remove from devDependencies
pnpm remove -g serve # remove global package
Output: (none — exits 0 on success)
Update packages
pnpm update # update all packages within their semver range
pnpm update lodash # update specific package
pnpm update --latest # update all packages to latest, ignoring ranges
pnpm update --interactive # pick packages interactively
pnpm update --recursive # update across all workspace packages
Output: (none — exits 0 on success)
Inspect packages
pnpm outdated # show packages with newer versions available
pnpm list # list installed packages (top level)
pnpm list --depth 2 # list with transitive deps up to depth 2
pnpm why express # explain why a package is installed
Output (pnpm outdated):
Package Current Wanted Latest
express 4.18.2 4.18.2 4.19.2
typescript 5.3.3 5.3.3 5.5.2
Run scripts
pnpm run build # run the "build" script from package.json
pnpm run dev # run "dev"
pnpm start # shorthand for pnpm run start
pnpm test # shorthand for pnpm run test
pnpm run lint -- --fix # pass extra args after --
Output: (none — exits 0 on success)
Unlike npm, pnpm does not require
runfor arbitrary script names —pnpm buildandpnpm run buildare equivalent for any script.
pnpm dlx — run a package without installing
pnpm dlx is pnpm's equivalent of npx. It downloads and executes a package in a temporary environment, leaving nothing behind in your project.
pnpm dlx create-next-app my-app # scaffold a Next.js project
pnpm dlx create-vite my-app # scaffold a Vite project
pnpm dlx cowsay "hello pnpm" # one-off CLI tool
pnpm dlx typescript --version # run a specific binary
Output (pnpm dlx cowsay "hello pnpm"):
____________
< hello pnpm >
------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
pnpm exec — run a local binary
pnpm exec runs a binary from the project's node_modules/.bin without adding it to a script in package.json.
pnpm exec tsc --noEmit # run TypeScript compiler
pnpm exec eslint src/ # run ESLint
pnpm exec jest --watch # run Jest in watch mode
Output: (none — exits 0 on success)
The pnpm store
pnpm store path # show where the global store lives
pnpm store status # check store integrity
pnpm store prune # remove unreferenced packages from the store
Output (pnpm store path):
/home/user/.local/share/pnpm/store/v3
Output (pnpm store prune):
Removed 23 packages (freed 142 MB)
Run
pnpm store pruneperiodically to reclaim disk space from package versions that are no longer referenced by any project.
Workspaces
pnpm has built-in monorepo support through a pnpm-workspace.yaml file at the repository root.
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
- "!**/__tests__/**" # exclude test directories
# Install dependencies across all workspace packages
pnpm install
# Run a script in a specific package (--filter / -F)
pnpm -F "my-app" run build
pnpm -F "./packages/ui" run dev
# Run a script in all workspace packages
pnpm -r run test
pnpm -r run build
# Add a dep to a specific workspace package
pnpm -F "my-app" add react
# Add a local workspace package as a dependency
pnpm -F "my-app" add "my-ui@workspace:*"
Output (pnpm -r run build):
Scope: 4 of 4 workspace projects
packages/ui build$ tsc
packages/utils build$ tsc
apps/api build$ tsc
apps/web build$ next build
.npmrc settings for pnpm
pnpm reads .npmrc files but also honours pnpm-specific keys:
# .npmrc
strict-peer-dependencies=false # don't fail on peer dep conflicts
auto-install-peers=true # automatically install missing peer deps
shamefully-hoist=false # keep strict node_modules (default)
shell-emulator=true # emulate POSIX shell on Windows for scripts
node-linker=hoisted # use npm-style hoisted layout (not recommended)
public-hoist-pattern[]=*eslint* # hoist eslint packages to the root
Avoid
shamefully-hoist=trueunless you are migrating a legacy project. It defeats pnpm's strict isolation guarantees and can hide dependency declaration bugs.
Common pnpm vs npm command equivalents
| Task | npm | pnpm |
|---|---|---|
| Install deps | npm install | pnpm install |
| Add package | npm install lodash | pnpm add lodash |
| Add dev dep | npm install -D jest | pnpm add -D jest |
| Remove | npm uninstall lodash | pnpm remove lodash |
| Run script | npm run build | pnpm run build (or pnpm build) |
| Execute binary | npx eslint | pnpm dlx eslint or pnpm exec eslint |
| List packages | npm list | pnpm list |
| Update all | npm update | pnpm update |
| Prune store | — | pnpm store prune |
How the content-addressable store works
The content-addressable store (CAS) is the single global directory where every version of every package ever installed by pnpm on the machine lives exactly once. Each file is stored under a path derived from its SHA-512 content hash, so two packages that ship identical files (common with index.d.ts shims or shared utilities) literally point at the same disk inode. Reach for this knowledge when you need to explain pnpm's disk savings, debug a corrupted install, or understand why deleting node_modules is essentially free.
pnpm store path
Output:
/home/alice/.local/share/pnpm/store/v3
The store layout looks like this:
~/.local/share/pnpm/store/v3/
├── files/ # actual file content keyed by hash
│ ├── 00/0a1f8e3.../package.json
│ ├── 00/0b2c4d5.../index.js
│ └── ...
├── metadata/ # cached registry responses (.json.gz)
│ └── registry.npmjs.org/...
└── index.json # store version + integrity records
A project's node_modules/.pnpm mirrors that store via hard links (default on the same filesystem) or reflinks (APFS, Btrfs, ZFS — copy-on-write at zero cost):
# Find which packages a single store file is referenced by
ls -li ~/.local/share/pnpm/store/v3/files/00/0a*/package.json
Output:
12345678 -rw-r--r-- 4 alice alice 487 May 12 14:02 package.json
The 4 in the link-count column means four projects on disk share that exact file — no copying happened when they installed.
Force an integrity check (compares every file against its recorded hash; reports drift from disk corruption or manual edits):
pnpm store status
Output:
Verifying store at /home/alice/.local/share/pnpm/store/v3
All 12847 files are intact.
When you migrate to a new disk or filesystem, point pnpm at the new store path in .npmrc (store-dir=/mnt/big/pnpm-store) so old projects don't break their hard links.
node_modules layout and isolation
pnpm's node_modules is flat at the top level only for declared dependencies. Everything else lives under node_modules/.pnpm/<name>@<version>/node_modules/<name>. This is the strict mode that prevents accidental access to undeclared transitive dependencies — a class of bug that npm and Yarn Classic's flat hoisting hides until you ship.
my-app/
└── node_modules/
├── express → .pnpm/express@4.19.2/node_modules/express
├── react → .pnpm/react@18.3.1/node_modules/react
└── .pnpm/
├── express@4.19.2/
│ └── node_modules/
│ ├── express # the actual package
│ ├── body-parser → ../../body-parser@1.20.2/...
│ └── ... # only its declared deps
├── body-parser@1.20.2/
└── react@18.3.1/
If my-app/src/index.js does require("body-parser") without declaring it, the import fails — body-parser is not in the top-level node_modules. That's the whole point: strict mode forces you to declare every direct dependency. Convert a project to discover undeclared deps:
pnpm install --strict-peer-dependencies
Output: (none — exits 0 on success)
Hoisting modes (node-linker)
node-linker in .npmrc controls how pnpm materialises node_modules. Pick the mode based on tooling compatibility, not preference — most modern projects are fine on the default isolated.
| Mode | What it does | When to use |
|---|---|---|
isolated (default) | Symlinks to .pnpm/ store; strict isolation | New projects; any modern toolchain |
hoisted | Flat node_modules like npm/Yarn Classic | Legacy projects, React Native, some bundlers |
pnp | Plug'n'Play resolution (no node_modules) | Maximum strictness, Yarn PnP parity |
# .npmrc — switch a project to npm-style flat hoisting
node-linker=hoisted
rm -rf node_modules pnpm-lock.yaml
pnpm install
Output:
Packages: +312
Progress: resolved 312, reused 0, downloaded 312, added 312, done
The public-hoist-pattern and hoist-pattern keys give you finer-grained control. public-hoist-pattern lifts matching packages to the top-level node_modules so editors and tools that scan the project root (ESLint plugins, TypeScript path resolution) can find them.
# .npmrc — make every eslint-related package visible at the root
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@types/*
shamefully-hoist=trueis equivalent topublic-hoist-pattern[]=*— it lifts every transitive dependency to the root, defeating strict isolation. Only use it as a temporary unblock when migrating; fix the underlying missing-dependency declarations instead.
Workspaces — monorepo deep-dive
A pnpm workspace is a set of packages declared in pnpm-workspace.yaml at the repository root. pnpm installs each package's node_modules independently (preserving strict isolation per package) while symlinking intra-workspace dependencies so you can edit a shared library and see changes in dependent apps immediately.
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
- "tools/*"
- "!**/__fixtures__/**"
- "!**/dist/**"
# Optional pinning of catalogs (pnpm 9.5+): centrally manage versions
catalog:
react: ^18.3.1
typescript: ^5.5.0
# Named catalogs
catalogs:
legacy:
react: ^17.0.2
Use the catalog in a workspace package.json — every package that references "react": "catalog:" picks up the same version automatically.
{
"dependencies": {
"react": "catalog:",
"typescript": "catalog:"
}
}
Filtering workspace operations
--filter (alias -F) is pnpm's superpower for monorepos. It selects packages by name, path, dependency relationship, or git diff and runs the command against only those.
# By name (glob supported)
pnpm -F "@my-org/ui" run build
pnpm -F "@my-org/*" run test
# By path
pnpm -F "./apps/web" run dev
pnpm -F "./packages/**" run lint
# By dependency relationship — run "test" in ui AND everything that depends on it
pnpm -F "...@my-org/ui" run test
# Inverse — run in ui's own dependencies
pnpm -F "@my-org/ui..." run build
# By git diff (CI-friendly: only test what changed)
pnpm -F "...[origin/main]" run test
pnpm -F "[HEAD~1]" run build
Output (pnpm -F "...[origin/main]" run test):
Scope: 3 of 8 workspace projects
@my-org/ui test$ vitest run
✓ src/Button.test.tsx (3 tests)
@my-org/api test$ vitest run
✓ src/routes.test.ts (8 tests)
apps/web test$ vitest run
✓ src/App.test.tsx (5 tests)
Workspace dependency syntax
The workspace: protocol marks a dependency as resolved from a sibling workspace package. pnpm replaces it with the actual version on pnpm publish so the published package is consumable outside the monorepo.
{
"dependencies": {
"@my-org/ui": "workspace:*",
"@my-org/utils": "workspace:^",
"@my-org/api": "workspace:~",
"@my-org/types": "workspace:1.2.3"
}
}
| Specifier | Resolves to | Publish behaviour |
|---|---|---|
workspace:* | Any version in the workspace | Replaced with the exact installed version |
workspace:^ | Caret of the workspace version | Replaced with ^<version> |
workspace:~ | Tilde of the workspace version | Replaced with ~<version> |
workspace:1.2.3 | Pinned version (errors if absent) | Replaced with 1.2.3 |
Recursive scripts
pnpm -r (recursive) runs a script in every workspace package that defines it. Combine with --parallel for independent tasks (lint, typecheck) and --workspace-concurrency=1 for serial tasks (db migrations).
# Run "build" in every package, respecting dependency order (topological)
pnpm -r run build
# Run "test" in parallel across all packages
pnpm -r --parallel run test
# Skip packages where the script doesn't exist
pnpm -r --if-present run lint
# Stream output as it happens instead of buffering
pnpm -r --stream run dev
Output (pnpm -r run build):
Scope: 4 of 4 workspace projects
@my-org/types build$ tsc
@my-org/utils build$ tsc # waits for types
@my-org/ui build$ tsc # waits for utils
@my-org/web build$ next build # waits for ui
Peer dependencies and auto-install
pnpm enforces peer dependencies strictly by default — a package that declares "peerDependencies": { "react": ">=17" } will refuse to install if the consumer doesn't provide a matching react. auto-install-peers=true (the default since v8) installs them automatically, mimicking npm's behaviour.
# .npmrc
strict-peer-dependencies=true # warn → error if peer is missing
auto-install-peers=true # auto-install missing peers
resolve-peers-from-workspace-root=true # for monorepos: hoist peer resolution
Inspect peer dependency conflicts:
pnpm install
Output (when conflicts exist):
WARN Issues with peer dependencies found
.
└─┬ @testing-library/react 14.2.1
├── ✕ unmet peer react@^18.0.0: found 17.0.2
└── ✕ unmet peer react-dom@^18.0.0: found 17.0.2
Security auditing
pnpm audit runs the same advisory check as npm audit against the GitHub Advisory Database but is significantly faster because pnpm's lockfile already has the resolved tree. Audit fix is supported but more conservative than npm's --force — it never bumps a major version automatically.
# Audit production dependencies
pnpm audit
# Audit only production dependencies (skip devDependencies)
pnpm audit --prod
# Apply non-breaking fixes
pnpm audit --fix
# JSON output for CI parsing
pnpm audit --json
# Audit with a severity floor
pnpm audit --audit-level moderate
Output (pnpm audit):
┌─────────────────────┬────────────────────────────────────────────┐
│ moderate │ Prototype Pollution in lodash │
├─────────────────────┼────────────────────────────────────────────┤
│ Package │ lodash │
│ Vulnerable versions │ <4.17.21 │
│ Patched versions │ >=4.17.21 │
│ Paths │ . > lodash@4.17.15 │
└─────────────────────┴────────────────────────────────────────────┘
1 vulnerabilities found
Severity: 1 moderate
Ignore specific advisories during a migration (don't make this permanent):
# .npmrc
audit-allow=GHSA-rv95-896h-c2vc
Lockfile internals — pnpm-lock.yaml
pnpm-lock.yaml records the resolved version, integrity hash, and full dependency graph in YAML. Unlike package-lock.json (which can balloon to many megabytes), pnpm's lockfile is alphabetised and deduplicated — its diffs are reviewable in code review.
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
express:
specifier: ^4.19.2
version: 4.19.2
packages:
express@4.19.2:
resolution: {integrity: sha512-...}
engines: {node: '>= 0.10.0'}
snapshots:
express@4.19.2:
dependencies:
body-parser: 1.20.2
cookie: 0.6.0
Always commit pnpm-lock.yaml. To reproduce CI behaviour locally:
pnpm install --frozen-lockfile
Output: (none — exits 0 on success)
When you see merge conflicts, accept either side and re-run install — pnpm regenerates a deterministic file:
git checkout --theirs pnpm-lock.yaml
pnpm install
Output: (none — exits 0 on success)
Hooks and overrides
pnpm.overrides (in package.json) lets you force a specific version of any transitive dependency — useful for security patches you can't get upstream maintainers to ship.
{
"pnpm": {
"overrides": {
"minimist@<1.2.6": "^1.2.6",
"axios": "1.7.4",
"follow-redirects@<1.15.4": "^1.15.4"
},
"patchedDependencies": {
"lodash@4.17.21": "patches/lodash@4.17.21.patch"
},
"neverBuiltDependencies": ["esbuild", "@swc/core"],
"onlyBuiltDependencies": ["sharp"]
}
}
neverBuiltDependencies and onlyBuiltDependencies control which packages may run install scripts — a defence against malicious postinstall scripts in supply-chain attacks (pnpm v10+ defaults to denying all and prompts you to allow each).
Apply local patches with pnpm patch:
# Open a temporary directory with the package's source
pnpm patch lodash@4.17.21
Output:
You can now edit the following folder: /tmp/pnpm-patch-abc/lodash@4.17.21
Once you're done with your changes, run:
pnpm patch-commit /tmp/pnpm-patch-abc/lodash@4.17.21
Edit files, then commit the patch:
pnpm patch-commit /tmp/pnpm-patch-abc/lodash@4.17.21
Output: (none — exits 0 on success)
pnpm vs npm vs Yarn vs Bun
A full-fat comparison covering install speed, disk usage, ergonomics, and ecosystem maturity. Speeds are typical figures from a fresh install of a 300-package project on a modern SSD.
| Feature | npm v10 | pnpm v9 | Yarn v4 | Bun v1.2 |
|---|---|---|---|---|
| Lockfile | package-lock.json | pnpm-lock.yaml | yarn.lock | bun.lock |
| Cold install | ~45 s baseline | ~18 s (~2.5× faster) | ~25 s (~1.8× faster) | ~3 s (~15× faster) |
| Warm install | ~12 s | ~3 s | ~5 s | ~0.8 s |
| Disk per project | ~250 MB (copies) | ~5 MB (links) | ~15 MB (PnP cache) | ~250 MB (copies) |
| Strict isolation | No | Yes | Yes (PnP) | No (hoisted) |
| Zero-installs | No | No | Yes | No |
| Workspaces | Yes | Yes (with catalogs) | Yes (with constraints) | Yes |
| Catalogs / version pinning | No | Yes | Yes (constraints.pro) | No |
| Build sandbox | No | Yes (v10+) | No | No |
| Native TypeScript | No (npm itself) | No | No | Yes |
| Cross-platform shell | No | shell-emulator=true | script-shell | Yes (built-in) |
Pick pnpm if disk usage and strict dependency hygiene matter (large monorepos, many projects on one machine). Pick npm if you want zero setup and maximum tooling compatibility. Pick Yarn v4 for Plug'n'Play and constraints. Pick Bun for raw speed when its compatibility quirks are acceptable. See
bunfor the Bun deep-dive.
Common pitfalls
pnpm installfailing in CI with "ERR_PNPM_OUTDATED_LOCKFILE" —package.jsonchanged butpnpm-lock.yamlwasn't regenerated. Runpnpm installlocally and commit the new lockfile. Never use--no-frozen-lockfilein CI as a workaround; it defeats reproducibility.- "Module not found" only inside the monorepo — a workspace package imports a dependency it doesn't declare. pnpm's strict isolation surfaces this; npm's flat hoisting hides it. Add the missing dep to that workspace's
package.json. - Husky / lint-staged not running — they rely on packages being hoisted. Add
public-hoist-pattern[]=*husky*to.npmrcor run them viapnpm exec. - Slow installs on Windows / over a VPN — disable
verify-store-integrityfor trusted environments and increase concurrency:child-concurrency=10in.npmrc. pnpm dlxrunning the wrong version — by defaultdlxcaches per-process; pass--package=name@versionto pin, or--silentto skip cache reads.- CI cache misses every run — cache
~/.local/share/pnpm/store/v3(Linux),~/Library/pnpm/store/v3(macOS), or%LOCALAPPDATA%\pnpm\store\v3(Windows) keyed onpnpm-lock.yamlhash. Don't cachenode_modules— pnpm re-links from the store in seconds. - Postinstall scripts not running in pnpm v10+ — by design. Add the package to
pnpm.onlyBuiltDependenciesinpackage.jsonafter auditing it. workspace:*shipped to npm — withoutpnpm publish(which rewrites the protocol),workspace:specifiers in published packages cause install failures. Always publish viapnpm -F <pkg> publish.
Real-world recipes
Add a shared TypeScript config across a monorepo
A pattern this site's stack uses: one package owns the tsconfig base, every workspace extends it. pnpm's symlinks make the path stable across packages.
// packages/tsconfig/package.json
{
"name": "@my-org/tsconfig",
"version": "0.0.0",
"files": ["base.json", "node.json", "react.json"]
}
// apps/web/package.json
{
"devDependencies": {
"@my-org/tsconfig": "workspace:*"
}
}
// apps/web/tsconfig.json
{
"extends": "@my-org/tsconfig/react.json",
"compilerOptions": { "outDir": "dist" }
}
pnpm install
pnpm -F "./apps/web" run build
Output:
@my-org/web build$ tsc
[done in 1.2s]
Cache the pnpm store in GitHub Actions
A 90-second install becomes 8 seconds when the store is restored from cache. Key on the lockfile so a pnpm-lock.yaml change is the only cause of a fresh download.
# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm -r run test
pnpm install --frozen-lockfile
Output:
Lockfile is up to date, resolution step is skipped
Progress: resolved 312, reused 312, downloaded 0, added 312, done
Migrate a yarn project to pnpm without re-resolving versions
pnpm import reads yarn.lock (or package-lock.json) and produces a pnpm-lock.yaml with the same resolved versions — zero churn for downstream consumers.
pnpm import
rm yarn.lock # or package-lock.json
rm -rf node_modules
pnpm install --frozen-lockfile
Output (pnpm import):
Importing package selectors from yarn.lock
Imported 312 packages
Run only the workspace packages affected by a PR
In CI, run lint / test only against packages that depend (directly or transitively) on changed files — keeps PR times bounded even as the monorepo grows.
pnpm -F "...[origin/main]" run lint
pnpm -F "...[origin/main]" run test
pnpm -F "...[origin/main]" run build
Output:
Scope: 5 of 24 workspace projects
[5 packages × 3 commands = 15 invocations, others skipped]
Clean up disk space across many projects
After a year of development the store fills up. prune removes unreferenced packages; prune --force also discards the metadata cache so subsequent installs re-validate against the registry.
pnpm store prune
du -sh ~/.local/share/pnpm/store
Output:
Removed 187 packages
Freed 3.2 GB
1.8G /home/alice/.local/share/pnpm/store