pnpm's isolated node_modules as a dependency hygiene check
pnpm's symlink-based node_modules makes phantom dependencies fail fast. This post explains the isolated layout, how to verify it locally, and the tooling trade-offs of strict resolution.
10 Jul 2025, 10:22 UTC

Code that imports a package it never declares can pass all local tests under npm or Yarn and then break in a fresh install. pnpm makes that class of bug visible by design, because it does not hoist transitive dependencies into the top-level node_modules.
The useful takeaway is not disk savings. It is that pnpm's symlink-based layout acts like a lint for your dependency graph: if a file resolves a package you did not list, the install will fail to resolve it.
What pnpm puts on disk instead of a flat tree
pnpm keeps a global content-addressable store of package contents. On install it hard-links files from the store into the project and creates symlinks from node_modules to those files.
Only packages you directly declare appear as first-class entries under node_modules. Transitive dependencies live under node_modules/.pnpm and are reachable only through the dependency graph that pnpm builds for each parent. Peer-dependency variants are encoded in the .pnpm path names, so different peer contexts can coexist without duplicating the whole package beyond hard links.
The result is an isolated layout: a package cannot see its sibling's dependencies unless those dependencies are also declared for it.
Phantom dependencies and why they matter
A phantom dependency is an import that works because a transitive dependency was hoisted to the top level by npm or Yarn. The code never declares the package, so the dependency graph is inaccurate.
With pnpm's strict resolution, an undeclared import fails at require/import time because the symlink chain does not expose the transitive package at the top level. That failure is the signal you want: it forces an explicit dependency declaration instead of relying on accidental hoisting.
Verify the behavior locally without trusting the docs
Run the check in a throwaway directory where you control the package.json. No elevated permissions are needed.
# in an empty folder
pnpm --version
mkdir demo && cd demo
pnpm init
Create a minimal dependency shape. For example, declare a direct dependency A and let A depend on C. Do not declare C yourself.
pnpm add A
Inspect the layout.
ls node_modules
ls node_modules/.pnpm
You should see only A at the top level of node_modules. The real files for A and its transitive dependencies are under node_modules/.pnpm, linked via symlinks. There is no top-level C folder.
Now add a source file that imports C directly.
echo "import c from 'C'" > index.js
node index.js
Under pnpm the resolve will fail because C is not declared for the project. The same file would resolve under a hoisted npm install where C was lifted to the top level. That contrast is the diagnostic.
Check the store location if you want to understand reuse across projects.
pnpm store path
Risk: copying node_modules across filesystems or into a Docker image can break hard links and inflate size. In Docker, mount the store as a cache volume rather than copying node_modules into layers.
When strictness collides with tooling
Some older build tools, bundlers, and scripts assume a flat hoisted layout or resolve paths via require.resolve in ways that break with symlinks. If you hit such a case, pnpm provides an escape hatch.
# .npmrc in project root
node-linker=hoisted
This makes pnpm emulate npm's flat layout. You lose phantom-dependency protection and some disk reuse, but you gain compatibility. Treat it as a temporary compatibility mode, not a default.
In monorepos the strict layout pairs well with workspace:* protocols. Internal packages are linked via the same store, and shared dependencies are deduplicated across workspace members without accidental cross-package visibility.
Practical limits
Hard link behavior varies across filesystems and is fragile when node_modules is archived or moved. Peer dependency handling and default hoisting rules have changed across pnpm major versions, so confirm options against the major version you run with pnpm --version and the docs for that major version before publishing version-sensitive claims.
Do not assume specific disk-space percentages; savings depend on tree shape and how many versions you actually share.
Start by running pnpm install on a small, non-critical app, inspect node_modules and node_modules/.pnpm, and deliberately try an undeclared import to see the failure mode. If your toolchain survives, you get a free check on dependency hygiene on every install.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.