How pnpm's Store and Symlinked node_modules Work in Practice
pnpm stores each package version once in a global content-addressable store and builds node_modules from hard links and symlinks, saving disk space and blocking phantom dependencies by default.
04 Nov 2025, 07:38 UTC

The answer in one sentence
pnpm keeps one copy of each package version in a global content-addressable store, then builds your project's node_modules from hard links into a virtual store (node_modules/.pnpm) plus symlinks, which saves disk space and blocks phantom dependencies by default.
If you have ever run out of disk space on a large monorepo, or had a project work locally but fail in CI with "cannot find module", this layout is the cause and the fix. Understanding it lets you decide when pnpm's strictness helps you and when you need to relax it.
The store and the virtual store
Every package pnpm downloads is unpacked once into a global store, by default at ~/.pnpm-store (check with pnpm store path). Files are addressed by content hash, so ten projects using lodash@4.17.21 share a single physical copy on disk.
Inside your project, node_modules/.pnpm is the "virtual store": it contains hard links to the global store, arranged as .pnpm/<name>@<version>/node_modules/<name>. The packages you declared are then symlinked from the virtual store to the root of node_modules. A hard link is a second directory entry pointing at the same bytes — no copying, no extra disk usage. Because the root only contains symlinks to your declared dependencies, require('lodash') works, but require('semver') (a transitive dependency you never declared) throws Cannot find module. That is phantom-dependency prevention: the layout enforces what package.json says, rather than what happens to be hoisted into reach.
A worked example
Say your package.json declares express. After pnpm install, the layout looks like this:
node_modules/
├── .pnpm/
│ ├── express@4.21.2/
│ │ └── node_modules/
│ │ ├── express/ (hard links into the global store)
│ │ ├── accepts/
│ │ └── ...
│ └── ...
└── express -> .pnpm/express@4.21.2/node_modules/express
Only express is symlinked at the root. Express's own dependencies live inside .pnpm/express@4.21.2/node_modules/ as symlinks to sibling entries in the virtual store, so express can resolve them, but your code cannot.
Verifying the layout yourself
Run these in your project root (no special permissions needed):
# Confirm the virtual store exists
ls node_modules/.pnpm
# Confirm strict resolution: this should fail with MODULE_NOT_FOUND
node -e "require('accepts')"
# Confirm the symlink chain
ls -la node_modules/express
To see disk savings, run du -sh node_modules on two projects sharing dependencies; each project's node_modules is mostly links, so the incremental cost of a new project is near zero. Note that du may report hard-linked files oddly depending on flags, so treat it as indicative, not exact.
When the strictness hurts: shamefully-hoist
Some tools — older bundlers, certain VS Code setups, scripts that walk node_modules — assume npm/yarn's flat layout. For those, add to .npmrc:
shamefully-hoist=true
This hoists everything to the root, mimicking npm. Use it as a compatibility escape hatch, not a default: you lose phantom-dependency protection and some disk savings. A less blunt option is public-hoist-pattern, which hoists only matching packages (e.g., *eslint* for plugins that must be resolvable from the root).
Limits and common mistakes
- Cross-filesystem installs: hard links cannot span partitions. If your store and project are on different devices, pnpm falls back to copying, and you lose the disk savings. Keep the store on the same filesystem as your projects, or set
store-dirin.npmrcto a local path. - Transitive dependency reliance: if your app imports a package that only works because npm used to hoist it, pnpm will fail at runtime. The fix is to add the package to
package.json, not to hoist. - CI caches: caching
node_modulesis fragile with pnpm; cache the store directory instead (pnpm store path) and let install rebuild links. - Legacy scripts: tools that resolve
node_modules/<pkg>directly break on symlinks. Test withshamefully-hoistbefore assuming a tool is incompatible — often the tool just needs the hoisted path.
The layout is stable across pnpm 8 and 9; verify against your version with pnpm --version and the .pnpm check above. If you need to move back to npm, delete node_modules and pnpm-lock.yaml, then npm install — the store itself is untouched and reusable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.