pnpm 06 📦 Strict Dependency Trees and Hoisting Limitations (symlinks vs flat node_modules)
The previous chapters established how pnpm links packages from a global store and constructs a virtual store. This chapter explains the consequence: a strictly isolated node_modules layout that forbids access to undeclared dependencies. npm and Yarn Classic hoist packages to the root, allowing a project to import anything in the tree. pnpm’s default behavior is the opposite. The root node_modules contains symlinks only to the dependencies you declared, and every transitive dependency is locked inside its parent’s isolated folder. The strictness catches missing declarations at development time—the exact class of bug that hoisting hides until production.
Key point: pnpm’s strictness means that importing an undeclared dependency fails immediately. The bug is the missing declaration, not the package manager. Hoisting is a compatibility shim, not a feature.
Why strict dependency trees exist
The ghost dependency problem. In a hoisted node_modules, a package that forgets to declare its dependency on lodash can still require('lodash') because hoisting placed lodash at the root. This works until the parent package that installed lodash changes its own dependencies, and lodash disappears from the hoisted root. The code that worked locally now fails in production, and the error message points to a missing module—not to the actual bug, which is the undeclared dependency .
The production simulation problem. Production deployments install only the declared dependencies (via npm ci or pnpm install --prod). If a project relies on hoisting for a transitive dependency, that dependency may not be installed in production because it was never declared. pnpm’s strict tree mirrors the production environment during development. What works locally works in production, and what fails locally is a declaration bug that must be fixed.
The workspace coupling problem. In a monorepo with npm or Yarn Classic, a workspace package can import a dependency declared only by a sibling workspace because both are hoisted to the shared root. This creates hidden coupling: removing the sibling workspace breaks the importing package. pnpm’s isolated layout prevents this by ensuring that a workspace can only import what it declares, plus what its dependencies declare and expose through their own boundaries.
The debugging problem. When a dependency fails to resolve in a hoisted layout, the error is often ambiguous. Is the package missing from the tree? Is it hoisted to an unexpected location? Is a version conflict causing the wrong copy to be resolved? In pnpm’s strict layout, the error is explicit: the package is not symlinked at the root because it was never declared. The fix is to add it to package.json, which is the correct action regardless.
The trade-off. Strictness breaks compatibility with packages that rely on undeclared dependencies. The real world contains many such packages. pnpm’s answer is a compatibility layer: hidden hoisting into .pnpm/node_modules. This layer allows broken packages to find their undeclared dependencies without exposing those dependencies to the project’s own code. The escape hatches (shamefully-hoist, node-linker=hoisted) sacrifice strictness entirely for tools that cannot function without a flat layout .
a. The default layout: strict and isolated
In pnpm’s default configuration, the root node_modules contains only symlinks to direct dependencies. Transitive dependencies live inside .pnpm and are linked to their parent packages via nested symlinks.
node_modules/
├── .pnpm/
│ ├── express@4.18.2/
│ │ └── node_modules/
│ │ ├── express/ ← hard-linked files
│ │ ├── body-parser -> ../../body-parser@1.20.1/node_modules/body-parser
│ │ └── accepts -> ../../accepts@1.3.8/node_modules/accepts
│ ├── body-parser@1.20.1/
│ │ └── node_modules/
│ │ ├── body-parser/
│ │ └── bytes -> ...
│ └── accepts@1.3.8/
│ └── node_modules/
│ └── accepts/
└── express -> .pnpm/express@4.18.2/node_modules/express
When the project’s own code calls require('express'), Node.js resolves the symlink to .pnpm/express@4.18.2/node_modules/express. When express itself calls require('body-parser'), Node.js starts from express‘s real location and follows the body-parser symlink inside the same folder. When the project’s own code calls require('body-parser'), Node.js starts from the project root, finds no node_modules/body-parser symlink, and fails. This is the strictness: the root only exposes what you declared.
The pnpm why <package> command explains the resolution chain. It shows which direct dependency pulled the package in, which is the diagnostic tool when a dependency is unexpectedly missing or present.
b. Hoisting: the hidden compatibility layer
pnpm does not eliminate hoisting entirely. It hides it. By default, every dependency in the tree is hard-linked or symlinked into node_modules/.pnpm/node_modules. This is a hidden directory, not at the root. When a package imports an undeclared dependency, Node.js walks up from the package’s real location to .pnpm/node_modules and finds it there.
The distinction is critical. The root node_modules contains only direct dependencies. The hidden .pnpm/node_modules contains every package in the tree. Your project’s code cannot see the hidden layer because Node.js starts from the root and stops when it finds a node_modules. The hidden layer exists for broken packages that forgot to declare their own dependencies.
The setting hoist=true (the default) enables this hidden hoisting. The setting hoist=false disables it entirely. The setting shamefully-hoist=true moves the hidden layer to the root, producing an npm-like flat layout.
c. The compatibility escape hatches
Some tools do not work with symlinks. They may resolve files using filesystem operations that do not follow symlinks, or they may require every dependency to be physically present at the root. For these tools, pnpm provides two settings that sacrifice strictness for compatibility.
# .npmrc
shamefully-hoist=true
This setting moves the hidden hoisted packages to the root node_modules. The result is an npm-like flat layout where every dependency is visible to the project. The strictness is gone, and ghost dependencies work again.
# .npmrc
node-linker=hoisted
This setting changes the linker entirely. Instead of creating symlinks to the virtual store, pnpm copies packages into a flat node_modules, exactly as npm and Yarn Classic do. The .pnpm directory is not used. Disk efficiency and strictness are both lost.
The difference between the two settings matters. shamefully-hoist keeps the virtual store and the hard-link efficiency; it just exposes the hidden layer at the root. node-linker=hoisted abandons the virtual store entirely, producing a traditional layout. The first is a minimal concession; the second is a full retreat.
Complete Example Session
# ============================================
# PART 1: INSTALL A DEPENDENCY
# ============================================
pnpm add express
# express is symlinked at the root.
# ============================================
# PART 2: TEST DECLARED IMPORT
# ============================================
node -e "require('express')"
# Works: express is a direct dependency.
# ============================================
# PART 3: TEST UNDECLARED IMPORT
# ============================================
node -e "require('body-parser')"
# Error: Cannot find module 'body-parser'
# It is installed, but not symlinked at the root.
# ============================================
# PART 4: INSPECT THE HIDDEN LAYER
# ============================================
ls node_modules/.pnpm/node_modules/ | head
# body-parser, accepts, cookie, debug, depd, ...
# Hidden hoisting makes them available to broken packages.
# ============================================
# PART 5: TEST FROM A BROKEN PACKAGE
# ============================================
# express requires body-parser correctly, so it works.
# A package that forgets to declare its dependencies
# would find them in .pnpm/node_modules.
# ============================================
# PART 6: FIX THE UNDECLARED IMPORT
# ============================================
pnpm add body-parser
node -e "require('body-parser')"
# Works: body-parser is now a direct dependency.
# ============================================
# PART 7: ENABLE SHAMEFUL HOISTING
# ============================================
echo "shamefully-hoist=true" > .npmrc
rm -rf node_modules
pnpm install
ls node_modules/ | head
# body-parser is now symlinked at the root.
node -e "require('body-parser')"
# Works without declaring it.
# ============================================
# PART 8: SWITCH TO FLAT LAYOUT
# ============================================
echo "node-linker=hoisted" > .npmrc
rm -rf node_modules
pnpm install
ls node_modules/express
# express is a real directory, not a symlink.
# ============================================
# PART 9: DISABLE HOISTING ENTIRELY
# ============================================
echo "hoist=false" > .npmrc
rm -rf node_modules
pnpm install
ls node_modules/.pnpm/node_modules/ 2>/dev/null || echo "No hidden hoisting"
# Broken packages will now fail at runtime.
The nine parts trace the strictness: declared import, undeclared import failure, hidden layer, fixing the declaration, shameful hoisting, flat layout, and disabled hoisting.
Quick Reference
Layout Settings
| Setting | Effect |
|---|---|
| (default) | Strict root + hidden hoisting |
hoist=false | Strict root, no hidden hoisting |
shamefully-hoist=true | Hoist to root (npm-like) |
node-linker=hoisted | Flat node_modules, no symlinks |
Default Directory Roles
| Path | Contents |
|---|---|
node_modules/<pkg> | Symlink to direct dependency |
node_modules/.pnpm/<pkg> | Real files (hard-linked from store) |
node_modules/.pnpm/node_modules/ | Hidden hoisting layer |
Diagnostic Commands
| Command | Purpose |
|---|---|
pnpm why <pkg> | Show why a package is installed |
pnpm list --depth=0 | List direct dependencies |
pnpm list --depth=1 | List direct + immediate transitives |
Best Practices
✅ Do This:
pnpm add body-parser
<!-- Declare every direct import --> # ✅
pnpm why body-parser
<!-- Trace resolution chain when confused --> # ✅
# Keep default strictness unless a tool breaks
- ✅ Strictness catches bugs early
❌ Don’t Do This:
# Import transitive dependencies directly
<!-- Fails under pnpm; declare them instead --> # ❌
# Enable shamefully-hoist to "fix" a broken package
<!-- The broken package should declare its deps --> # ❌
# Switch to node-linker=hoisted in production
<!-- Loses pnpm's disk savings and strictness --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Undeclared import fails | Strict root layout | Declare the dependency |
| Tool cannot find package | Tool does not follow symlinks | node-linker=hoisted |
| Disk usage not reduced | Symlinks count as separate in du | Check actual store size |
| Workspace dependency not found | Not declared in workspace | Add with workspace:* |
| Hidden hoisting ignored | hoist=false configured | Set hoist=true for compatibility |
Real-World Examples
1. Declare a Dependency
pnpm add body-parser
2. Trace Why a Package Exists
pnpm why body-parser
3. List Direct Dependencies
pnpm list --depth=0
4. Enable Shameful Hoisting
echo "shamefully-hoist=true" > .npmrc
5. Switch to Flat Layout
echo "node-linker=hoisted" > .npmrc
6. Disable Hidden Hoisting
echo "hoist=false" > .npmrc
7. Inspect Hidden Layer
ls node_modules/.pnpm/node_modules/
8. Verify Symlinks
ls -la node_modules/express
9. Test Strictness
node -e "require('body-parser')"
10. Reset After Config Change
rm -rf node_modules pnpm-lock.yaml && pnpm install
Visual
Default Strict Layout
┌──────────────────────────────────────────────┐
│ node_modules/ │
│ ├── express -> .pnpm/express@.../express │
│ │ (symlink to direct dep) │
│ └── .pnpm/ │
│ ├── express@.../node_modules/ │
│ │ ├── express/ (real files) │
│ │ └── body-parser -> ... │
│ │ (nested symlink for express) │
│ └── node_modules/ │
│ ├── body-parser (hidden hoist) │
│ └── accepts │
└──────────────────────────────────────────────┘
The root sees only express. The hidden layer sees everything.
Hoisting Settings Flow
┌──────────────────────────────────────────────┐
│ Default (hoist=true) │
│ Root: direct only │
│ Hidden: all packages │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ shamefully-hoist=true │
│ Root: all packages (npm-like) │
│ Virtual store: still used │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ node-linker=hoisted │
│ Root: all packages, no symlinks │
│ Virtual store: not used │
└──────────────────────────────────────────────┘
Each step sacrifices more strictness for compatibility.
Summary
| Item | Value |
|---|---|
| Default root layout | Symlinks to direct deps only |
| Default hidden layer | .pnpm/node_modules (all packages) |
| Undeclared import | Fails at runtime |
| Compatibility flag (keep symlinks) | shamefully-hoist=true |
| Compatibility flag (flat layout) | node-linker=hoisted |
| Strict-only flag | hoist=false |
| Diagnostic | pnpm why <pkg> |
Key takeaways:
- pnpm’s default layout is strict. The root
node_modulescontains symlinks only to declared dependencies. Undeclared imports fail immediately. - Hidden hoisting preserves compatibility without sacrificing strictness. Packages that forgot to declare dependencies can still find them in
.pnpm/node_modules, but the project’s own code cannot. shamefully-hoistandnode-linker=hoistedare the escape hatches. The first exposes the hidden layer at the root. The second abandons the virtual store entirely.- The strictness catches real bugs. A package that imports an undeclared dependency will fail in production when only declared dependencies are installed. pnpm fails in development, which is where the bug should be fixed.
pnpm whyis the diagnostic tool. When a package is unexpectedly missing or present, it shows the resolution chain.
Remember: pnpm’s strictness is not a side effect. It is the design. The root is a declaration of intent. Everything else is linked from there. When an import fails, the fix is to add the dependency, not to disable the strictness. The escape hatches exist for tools that cannot follow symlinks, not for code that forgot to declare its dependencies.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!