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 — project node_modules contain 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

bash
# 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:

bash
pnpm --version

Output:

text
9.12.1

Install project dependencies

bash
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:

text
Packages: +312
++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 312, reused 308, downloaded 4, added 312, done

Add packages

bash
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):

text
Packages: +57
++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 57, reused 55, downloaded 2, added 57, done

dependencies:
+ express 4.19.2

Remove packages

bash
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

bash
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

bash
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):

text
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

bash
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 run for arbitrary script names — pnpm build and pnpm run build are 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.

bash
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"):

text
 ____________
< 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.

bash
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

bash
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):

text
/home/user/.local/share/pnpm/store/v3

Output (pnpm store prune):

text
Removed 23 packages (freed 142 MB)

Run pnpm store prune periodically 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.

yaml
# pnpm-workspace.yaml
packages:
  - "packages/*"
  - "apps/*"
  - "!**/__tests__/**"   # exclude test directories
bash
# 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):

text
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:

ini
# .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=true unless 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

Tasknpmpnpm
Install depsnpm installpnpm install
Add packagenpm install lodashpnpm add lodash
Add dev depnpm install -D jestpnpm add -D jest
Removenpm uninstall lodashpnpm remove lodash
Run scriptnpm run buildpnpm run build (or pnpm build)
Execute binarynpx eslintpnpm dlx eslint or pnpm exec eslint
List packagesnpm listpnpm list
Update allnpm updatepnpm update
Prune storepnpm 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.

bash
pnpm store path

Output:

text
/home/alice/.local/share/pnpm/store/v3

The store layout looks like this:

text
~/.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):

bash
# Find which packages a single store file is referenced by
ls -li ~/.local/share/pnpm/store/v3/files/00/0a*/package.json

Output:

text
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):

bash
pnpm store status

Output:

text
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.

text
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:

bash
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.

ModeWhat it doesWhen to use
isolated (default)Symlinks to .pnpm/ store; strict isolationNew projects; any modern toolchain
hoistedFlat node_modules like npm/Yarn ClassicLegacy projects, React Native, some bundlers
pnpPlug'n'Play resolution (no node_modules)Maximum strictness, Yarn PnP parity
ini
# .npmrc — switch a project to npm-style flat hoisting
node-linker=hoisted
bash
rm -rf node_modules pnpm-lock.yaml
pnpm install

Output:

text
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.

ini
# .npmrc — make every eslint-related package visible at the root
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@types/*

shamefully-hoist=true is equivalent to public-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.

yaml
# 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.

json
{
  "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.

bash
# 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):

text
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.

json
{
  "dependencies": {
    "@my-org/ui": "workspace:*",
    "@my-org/utils": "workspace:^",
    "@my-org/api": "workspace:~",
    "@my-org/types": "workspace:1.2.3"
  }
}
SpecifierResolves toPublish behaviour
workspace:*Any version in the workspaceReplaced with the exact installed version
workspace:^Caret of the workspace versionReplaced with ^<version>
workspace:~Tilde of the workspace versionReplaced with ~<version>
workspace:1.2.3Pinned 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).

bash
# 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):

text
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.

ini
# .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:

bash
pnpm install

Output (when conflicts exist):

text
 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.

bash
# 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):

text
┌─────────────────────┬────────────────────────────────────────────┐
│ 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):

ini
# .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.

yaml
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:

bash
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:

bash
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.

json
{
  "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:

bash
# Open a temporary directory with the package's source
pnpm patch lodash@4.17.21

Output:

text
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:

bash
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.

Featurenpm v10pnpm v9Yarn v4Bun v1.2
Lockfilepackage-lock.jsonpnpm-lock.yamlyarn.lockbun.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 isolationNoYesYes (PnP)No (hoisted)
Zero-installsNoNoYesNo
WorkspacesYesYes (with catalogs)Yes (with constraints)Yes
Catalogs / version pinningNoYesYes (constraints.pro)No
Build sandboxNoYes (v10+)NoNo
Native TypeScriptNo (npm itself)NoNoYes
Cross-platform shellNoshell-emulator=truescript-shellYes (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 bun for the Bun deep-dive.

Common pitfalls

  1. pnpm install failing in CI with "ERR_PNPM_OUTDATED_LOCKFILE"package.json changed but pnpm-lock.yaml wasn't regenerated. Run pnpm install locally and commit the new lockfile. Never use --no-frozen-lockfile in CI as a workaround; it defeats reproducibility.
  2. "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.
  3. Husky / lint-staged not running — they rely on packages being hoisted. Add public-hoist-pattern[]=*husky* to .npmrc or run them via pnpm exec.
  4. Slow installs on Windows / over a VPN — disable verify-store-integrity for trusted environments and increase concurrency: child-concurrency=10 in .npmrc.
  5. pnpm dlx running the wrong version — by default dlx caches per-process; pass --package=name@version to pin, or --silent to skip cache reads.
  6. CI cache misses every run — cache ~/.local/share/pnpm/store/v3 (Linux), ~/Library/pnpm/store/v3 (macOS), or %LOCALAPPDATA%\pnpm\store\v3 (Windows) keyed on pnpm-lock.yaml hash. Don't cache node_modules — pnpm re-links from the store in seconds.
  7. Postinstall scripts not running in pnpm v10+ — by design. Add the package to pnpm.onlyBuiltDependencies in package.json after auditing it.
  8. workspace:* shipped to npm — without pnpm publish (which rewrites the protocol), workspace: specifiers in published packages cause install failures. Always publish via pnpm -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.

json
// packages/tsconfig/package.json
{
  "name": "@my-org/tsconfig",
  "version": "0.0.0",
  "files": ["base.json", "node.json", "react.json"]
}
json
// apps/web/package.json
{
  "devDependencies": {
    "@my-org/tsconfig": "workspace:*"
  }
}
json
// apps/web/tsconfig.json
{
  "extends": "@my-org/tsconfig/react.json",
  "compilerOptions": { "outDir": "dist" }
}
bash
pnpm install
pnpm -F "./apps/web" run build

Output:

text
@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.

yaml
# .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
bash
pnpm install --frozen-lockfile

Output:

text
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.

bash
pnpm import
rm yarn.lock          # or package-lock.json
rm -rf node_modules
pnpm install --frozen-lockfile

Output (pnpm import):

text
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.

bash
pnpm -F "...[origin/main]" run lint
pnpm -F "...[origin/main]" run test
pnpm -F "...[origin/main]" run build

Output:

text
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.

bash
pnpm store prune
du -sh ~/.local/share/pnpm/store

Output:

text
Removed 187 packages
Freed 3.2 GB
1.8G	/home/alice/.local/share/pnpm/store