pnpm in Practice: Content-Addressable Store, Strict node_modules, Workspaces, and Migration
Key takeaways
pnpm keeps one copy of each package version in a global store and links it into projects, which saves disk space and time. Its other big difference is a strict node_modules layout that only exposes declared dependencies. That strictness catches real bugs, and it is also the main source of migration problems.
Introduction
pnpm (performant npm) is a fast, disk space efficient package manager. It solves npm’s performance and disk space issues by using a global content-addressable store.
The Problem with npm
npm/yarn:
# Each project duplicates node_modules
project1/node_modules/lodash # 2MB
project2/node_modules/lodash # 2MB
project3/node_modules/lodash # 2MB
# Total: 6MB for 3 projects
pnpm:
# One copy in global store, hard links in projects
~/.pnpm-store/lodash # 2MB (once)
project1/node_modules/lodash # hard link (0 bytes)
project2/node_modules/lodash # hard link (0 bytes)
project3/node_modules/lodash # hard link (0 bytes)
# Total: 2MB for 3 projects
That picture is simplified. What pnpm actually builds has two layers:
node_modules/
├── express -> .pnpm/[email protected]/node_modules/express (symlink)
└── .pnpm/
├── [email protected]/node_modules/
│ ├── express/ (files hard-linked from the global store)
│ ├── body-parser -> ../../[email protected]/node_modules/body-parser
│ └── ... (express's own dependencies, as symlinks)
└── [email protected]/node_modules/body-parser/
The store holds each file once, addressed by its content hash, and projects get hard links to those files, so ten projects using the same package version cost the disk space of one. Hard links only work within one filesystem, so if the store and the project are on different drives, pnpm falls back to copying (or cloning on filesystems that support it).
The layout is the part that changes behavior. The top level of node_modules contains only the packages listed in your own package.json. Their dependencies live inside .pnpm and are reachable only from the packages that declare them. With npm and Yarn Classic, everything is hoisted to the top level, so your code can require('body-parser') even though you only installed express. That is a phantom dependency: it works by accident until Express stops using body-parser or changes its version. pnpm makes that import fail immediately, which is exactly what you want, and it is also the main reason migrations hit “Cannot find module” errors.
Installation
# Via npm
npm install -g pnpm
# Via Corepack (ships with current Node LTS; reads "packageManager" in package.json)
corepack enable
# Via standalone script
curl -fsSL https://get.pnpm.io/install.sh | sh -
# Via Homebrew (macOS)
brew install pnpm
# Via Volta
volta install pnpm
Verify installation:
pnpm --version
Basic Commands
Install Dependencies
# Install all dependencies
pnpm install
# Add a dependency
pnpm add express
# Add dev dependency
pnpm add -D typescript
# Add global package
pnpm add -g pm2
Remove Dependencies
# Remove package
pnpm remove express
# Remove dev dependency
pnpm remove -D typescript
Update Dependencies
# Update all dependencies
pnpm update
# Update specific package
pnpm update express
# Update to latest (ignore semver)
pnpm update express --latest
Speed and Disk Space
pnpm publishes regularly updated benchmarks against npm, Yarn, and Bun at pnpm.io/benchmarks, covering cold installs, installs with a warm cache, and installs with an existing lockfile. The pattern is consistent even if the exact numbers change between releases: the advantage is small on a first install with an empty cache, where network time dominates, and large on repeat installs and in CI with a restored store, because packages are linked rather than extracted again. Measure on your own project before quoting a multiplier to your team.
Disk savings are easiest to see on a machine with many projects:
pnpm store path # where the store lives
pnpm store prune # remove packages no project references anymore
pnpm store status checks whether packages in the store were modified after installation; it does not report sizes. To see how much space the store uses, check the directory size with du -sh "$(pnpm store path)". The store only grows until you prune it, so on CI caches and developer machines an occasional pnpm store prune keeps it bounded.
Workspaces (Monorepo)
Setup
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
Project structure:
monorepo/
├── pnpm-workspace.yaml
├── package.json
├── packages/
│ ├── ui/
│ │ └── package.json
│ └── utils/
│ └── package.json
└── apps/
├── web/
│ └── package.json
└── api/
└── package.json
Install Dependencies
# Install all workspace dependencies
pnpm install
# Install for specific package
pnpm --filter web add react
# Install for all packages
pnpm -r add lodash
Run Scripts
# Run script in specific package
pnpm --filter web dev
# Run in all packages
pnpm -r build
# Run in parallel
pnpm -r --parallel test
pnpm -r runs a script in every workspace package that defines it, in topological order: a package runs only after the workspace packages it depends on, so pnpm -r build builds utils before ui and ui before web. Independent packages still run concurrently (up to --workspace-concurrency, 4 by default). --parallel removes the ordering entirely and streams all output, which is right for long-running dev or test --watch processes and wrong for build, where a package may start compiling before its dependency’s dist exists. The resulting errors look like missing type declarations or Cannot find module 'ui' and only appear on clean checkouts, because locally the old dist is still there.
Workspace packages depend on each other with the workspace: protocol ("ui": "workspace:*"). pnpm links the local package instead of looking for ui on the registry, and it refuses to fall back to the registry if the workspace package is missing, which avoids silently installing an unrelated public package with the same name. When you pnpm publish, workspace:* is replaced with the actual version number, so published packages still have normal semver ranges.
Filtering
# Install dependencies for 'web' package
pnpm --filter web install
# Run build for all packages in 'packages' folder
pnpm --filter './packages/*' build
# Run dev for 'web' and its dependencies
pnpm --filter web... dev
# Run build for packages that depend on 'utils'
pnpm --filter ...utils build
The position of the three dots is the whole syntax: dots after a name (web...) add everything the package depends on, dots before (...utils) add everything that depends on it. A caret excludes the package itself, so web^... means “only web’s dependencies” and ...^utils “only its dependents”. Filters also accept git ranges: pnpm --filter "...[origin/main]" test selects packages changed since main plus their dependents, which is the usual way to test only what a pull request can affect. As with any git-based selection in CI, the checkout needs enough history to contain origin/main, or the filter matches nothing or fails.
A common surprise is that the filter matches the name field in package.json, not the directory name. If apps/web/package.json says "name": "@acme/web", then --filter web selects nothing and pnpm prints No projects matched the filters. Use the full name, a glob such as --filter "@acme/*", or a path filter like --filter ./apps/web.
Scripts
// package.json
{
"scripts": {
"dev": "pnpm --filter web dev",
"build": "pnpm -r build",
"test": "pnpm -r --parallel test",
"clean": "pnpm -r exec rm -rf dist"
}
}
Configuration
# .npmrc
# Store location (default is per-OS, e.g. ~/.local/share/pnpm/store on Linux)
store-dir=~/.pnpm-store
# Layout: "isolated" (default, symlinked, strict) | "hoisted" (flat, npm-like) | "pnp"
node-linker=isolated
# Expose selected transitive deps at the top level (for tools that need them)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
# Peer dependencies (these are the defaults since pnpm 8)
auto-install-peers=true
strict-peer-dependencies=false
The layout options are the ones to understand. node-linker=isolated is the default symlinked, strict layout described at the top. node-linker=hoisted produces a flat node_modules like npm’s, which sacrifices the phantom-dependency protection but fixes tools that do not follow symlinks, such as some React Native and older bundler setups. shamefully-hoist=true is the older, blunter version of that: it hoists everything to the top level. Before reaching for either, try public-hoist-pattern for the specific packages that need to be visible, typically ESLint plugins and configs, which ESLint resolves from the project root.
Migration from npm/yarn
Step 1: Convert the lockfile
pnpm import # creates pnpm-lock.yaml from package-lock.json or yarn.lock
rm -rf node_modules package-lock.json yarn.lock
Step 2: Install with pnpm
pnpm install
Step 3: Update CI/CD
# GitHub Actions
- uses: pnpm/action-setup@v4 # reads the version from "packageManager"
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
The order in step 1 matters. Deleting package-lock.json first and running pnpm install resolves every dependency from scratch, so you migrate the package manager and silently upgrade hundreds of transitive packages in the same commit. When something breaks, you cannot tell which change caused it. pnpm import carries over the exact versions from the existing lockfile, so the first pnpm install reproduces what you already tested.
Expect a few “Cannot find module” errors after the switch: those are the phantom dependencies described at the top. The right fix is to add each missing package to package.json explicitly. It is also worth checking your scripts: pnpm runs pre/post scripts only for scripts you invoke directly, and since pnpm 10 it no longer runs dependencies’ install scripts (postinstall, native builds) unless you allow them in onlyBuiltDependencies (or with pnpm approve-builds). Packages like esbuild, sharp, or prisma then need to be listed there, or their binaries are missing.
This is the migration trap I see most: a project moves to pnpm, CI passes, and a week later someone discovers that a code path uses a transitive dependency that was only reachable because of npm’s hoisting, or that a native package silently skipped its build step under pnpm 10. Running the full test suite, not just the build, on the migration branch catches both.
Real-World Example: Next.js Monorepo
Structure:
nextjs-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│ ├── web/
│ │ ├── package.json
│ │ └── next.config.js
│ └── admin/
│ ├── package.json
│ └── next.config.js
└── packages/
├── ui/
│ ├── package.json
│ └── src/
└── config/
└── package.json
pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
Root package.json:
{
"name": "nextjs-monorepo",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",
"dev:admin": "pnpm --filter admin dev",
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"test": "pnpm -r test"
},
"devDependencies": {
"typescript": "^5.0.0",
"eslint": "^8.0.0"
}
}
apps/web/package.json:
{
"name": "web",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.0.0",
"ui": "workspace:*"
}
}
packages/ui/package.json:
{
"name": "ui",
"version": "1.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"react": "^18.0.0"
}
}
Advanced Features
Patching Packages
# Create patch
pnpm patch [email protected]
# Edit files in temporary directory
# Save and generate patch
pnpm patch-commit /tmp/patch-123
# Patch is saved to patches/[email protected]
Overrides
// package.json
{
"pnpm": {
"overrides": {
"axios": "1.0.0",
"react@<18.0.0": "18.0.0"
}
}
}
Catalog
# pnpm-workspace.yaml
catalog:
react: ^18.0.0
typescript: ^5.0.0
// package.json
{
"dependencies": {
"react": "catalog:"
}
}
These three features solve different problems, and it helps to keep them apart. pnpm patch is for fixing a bug inside a dependency before upstream releases a fix: the patch file is committed and applied on every install, and it is tied to an exact version, so upgrading the package forces you to check whether the patch is still needed. Overrides force a version anywhere in the dependency tree, typically to pull in a security fix for a transitive dependency you do not control; since pnpm 10 they can also live in pnpm-workspace.yaml. Overrides are powerful and easy to forget, so leave a comment or commit message explaining why each exists. Catalogs (pnpm 9.5 and later) define a version once in pnpm-workspace.yaml so that every workspace package using catalog: gets the same React or TypeScript version; without them, a monorepo slowly drifts into several versions of the same library, which for React means the “Invalid hook call” error caused by two copies of React in one bundle.
Docker Integration
# Dockerfile
FROM node:22-alpine
# Install pnpm (version pinned by "packageManager" in package.json)
RUN corepack enable
WORKDIR /app
# Copy workspace files
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
COPY package.json ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source
COPY . .
# Build
RUN pnpm build
CMD ["pnpm", "start"]
With layer caching:
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /app
FROM base AS builder
COPY pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm fetch # download from the lockfile alone
COPY . .
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --offline --frozen-lockfile && pnpm build
RUN pnpm prune --prod # drop devDependencies after building
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
CMD ["node", "dist/index.js"]
pnpm fetch is designed for Docker: it downloads every package listed in pnpm-lock.yaml without needing any package.json, so that layer stays cached until the lockfile changes, even in a monorepo with dozens of packages. The later install --offline only links. Note that both RUN steps mount the same cache: a --mount=type=cache directory is not part of the image layer, so if the install step does not mount it, the store that fetch filled is invisible and the offline install fails with ERR_PNPM_NO_OFFLINE_TARBALL. Copying node_modules between stages works because the symlinks inside it are relative. Avoid corepack prepare pnpm@latest in images: “latest” makes builds non-reproducible, while the packageManager field pins the exact version for everyone. (Corepack itself is being unbundled from future Node.js releases, so newer setups may install pnpm with npm install -g pnpm@<version> instead.)
Troubleshooting
Problem: Peer dependency warnings
# Declare it yourself (in a library: as a peer and dev dependency)
pnpm add --save-peer react-dom
# Or configure .npmrc
echo "auto-install-peers=true" >> .npmrc
Peer dependency warnings mean a package expects the host project to provide something (React for a component library, ESLint for a plugin) and either nothing provides it or the provided version is outside the declared range. Since pnpm 8, auto-install-peers=true is the default, so missing peers are installed automatically, and what remains are version mismatches. Those deserve a look rather than silencing: two React versions in one app, or an ESLint plugin built for a different major version, fail at run time in ways that are much harder to trace than the install warning. If a mismatch is known to be harmless, pnpm.peerDependencyRules.allowedVersions in package.json records that decision for one package instead of hiding all warnings.
Problem: Module not found
# Best fix: declare the dependency you actually import
pnpm add body-parser
# Targeted fallback for tools that expect hoisting
echo "public-hoist-pattern[]=*eslint*" >> .npmrc
# Last resort: flat npm-like layout
echo "node-linker=hoisted" >> .npmrc
pnpm install
Work through these in order. A “Cannot find module” error under pnpm almost always means code imports a package it never declared. Adding it to package.json fixes the real bug. Hoisting options hide it again.
Problem: Slow installation
# Check that the store and the project are on the same drive (hard links need that)
pnpm store path
# Use a registry mirror closer to you if downloads are the bottleneck
pnpm config set registry https://registry.npmmirror.com
pnpm store prune removes unreferenced packages to save disk space; it does not make installs faster (a smaller store means more to download later). The usual causes of slow installs are a store on a different filesystem than the project, which forces copying instead of hard-linking, an empty store on every CI run (cache it), and network latency to the registry.
Team and CI settings that behave differently than expected
workspace: versions are rewritten on publish
{
"dependencies": {
"ui": "workspace:*"
}
}
The workspace: protocol makes pnpm link the local package and fail the install if no workspace package matches, instead of silently downloading a same-named package from the registry. It only exists inside the monorepo: pnpm publish (and pnpm pack) replace it with a real version, workspace:* becoming the exact current version and workspace:^ becoming a caret range. Publishing with npm publish skips that rewrite, and consumers get a manifest they cannot install.
Pin the pnpm version exactly
{
"packageManager": "[email protected]"
}
The packageManager field takes an exact version, not a range such as 10.x; Corepack and pnpm itself use it to make every developer and CI job run the same pnpm, which matters because lockfile formats and defaults change between major versions. Update it deliberately in its own commit, together with the regenerated pnpm-lock.yaml.
CI: frozen lockfile, cached store
pnpm install --frozen-lockfile
# cache this directory between CI runs (actions/setup-node with cache: pnpm does it)
pnpm store path
When pnpm detects a CI environment, --frozen-lockfile is already the default, so an out-of-date pnpm-lock.yaml fails the install instead of being rewritten; the explicit flag just documents the intent. Caching the store directory is what makes CI installs fast, because each run can hard-link from it instead of downloading. --offline fails as soon as one package is missing from the store; --prefer-offline, which uses cached metadata and packages and only goes to the network for what is missing, is usually the more useful flag.
Settings like auto-install-peers=true often get copied into .npmrc files, but it has been the default since pnpm 8, so listing it changes nothing. Keep .npmrc for settings that actually differ from the defaults, so a reader can tell which choices were deliberate.
Related Articles
- Nx: Smart Monorepo Build System
- Turborepo build optimization
- Node.js Module System: CommonJS and ES Modules Explained