| | |

pnpm 06 📦 Strict Dependency Trees and Hoisting Limitations (symlinks vs flat node_modules)

⏱️ Estimated read: 10 min | |

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

SettingEffect
(default)Strict root + hidden hoisting
hoist=falseStrict root, no hidden hoisting
shamefully-hoist=trueHoist to root (npm-like)
node-linker=hoistedFlat node_modules, no symlinks

Default Directory Roles

PathContents
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

CommandPurpose
pnpm why <pkg>Show why a package is installed
pnpm list --depth=0List direct dependencies
pnpm list --depth=1List 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

PitfallWhy It HappensFix
Undeclared import failsStrict root layoutDeclare the dependency
Tool cannot find packageTool does not follow symlinksnode-linker=hoisted
Disk usage not reducedSymlinks count as separate in duCheck actual store size
Workspace dependency not foundNot declared in workspaceAdd with workspace:*
Hidden hoisting ignoredhoist=false configuredSet 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

ItemValue
Default root layoutSymlinks to direct deps only
Default hidden layer.pnpm/node_modules (all packages)
Undeclared importFails at runtime
Compatibility flag (keep symlinks)shamefully-hoist=true
Compatibility flag (flat layout)node-linker=hoisted
Strict-only flaghoist=false
Diagnosticpnpm why <pkg>

Key takeaways:

  • pnpm’s default layout is strict. The root node_modules contains 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-hoist and node-linker=hoisted are 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 why is 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.

Was this helpful?


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!