| | |

pnpm 04 📦 Hard Links, Symbolic Links, and the pnpm Virtual Store (.pnpm)

⏱️ Estimated read: 9 min | |

The previous chapter established that pnpm stores packages once and links them into your project. This chapter explains the mechanism: the .pnpm virtual store and the two types of links that connect it to the rest of the filesystem. An understanding of this architecture is not academic. It explains why pnpm installs are fast, why disk usage is low, why some tools fail with “module not found” errors, and how to fix them without abandoning pnpm’s strictness.

Key point: pnpm creates a nested node_modules inside .pnpm. Packages are hard-linked from the global store into .pnpm. The root node_modules contains symlinks to direct dependencies. Transitive dependencies are symlinked inside their parent’s .pnpm folder, not at the root.


Why this architecture exists

The copying problem. A traditional package manager downloads a package and writes every file to node_modules. If you have ten projects using the same package, the disk contains ten copies of every file. The store eliminates this: files are written once and linked.

The hard link solution. A hard link is a directory entry that points to an inode. Multiple hard links can point to the same inode, and the filesystem treats them as separate files that happen to share data. Deleting one hard link does not delete the data until the last link is removed. Hard links are cheap: creating one is an inode update, not a data copy.

The symlink solution. A symbolic link (symlink) is a file containing a path to another file. When a program opens a symlink, the kernel follows the path to the target. Symlinks can point across filesystems; hard links cannot. pnpm uses symlinks at the root node_modules because the store and the project may be on different filesystems, and because symlinks can be created without modifying inode counts.

The hoisting problem. npm hoists dependencies to the root of node_modules to avoid deep nesting. This creates ghost dependencies: packages that appear in the root but were never declared. pnpm avoids hoisting by placing each package’s dependencies inside the package’s own .pnpm folder. Your project’s root node_modules only contains symlinks to direct dependencies.

The trade-off. The symlinked layout is incompatible with tools that do not follow symlinks or that assume a flat node_modules. This includes some older bundlers, test runners, and IDE plugins. The fix is either node-linker=hoisted or shamefully-hoist=true, both of which sacrifice strictness for compatibility.


a. The global store and hard linking

The global store is a directory of unpacked package files, keyed by content hash. It is located at pnpm store path (typically ~/.local/share/pnpm/store/v10 on Linux). Every file from every package version pnpm has installed exists there exactly once.

When pnpm installs a package, it copies the files into the store if they are missing, then creates hard links in the project’s .pnpm directory. The hard link points to the same inode as the store file, so the project sees the file as local, but no data is duplicated.

# Show store location
pnpm store path
# Output: /home/user/.local/share/pnpm/store/v10

# Show disk usage of store
du -sh $(pnpm store path)

The store can be inspected with pnpm store status, which checks for corrupted files or missing links. Over time, old packages accumulate in the store. pnpm store prune removes packages that no project references.


b. The .pnpm virtual store and nested dependency graphs

The .pnpm directory inside node_modules is the virtual store. Its structure mirrors the dependency graph.

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/          ← hard-linked files
│   │       ├── bytes -> ...
│   │       └── ...
│   └── accepts@1.3.8/
│       └── node_modules/
│           └── accepts/              ← hard-linked files
└── express -> .pnpm/express@4.18.2/node_modules/express

The root node_modules/express is a symlink to .pnpm/express@4.18.2/node_modules/express. Inside that folder, express‘s dependencies (body-parser, accepts) are symlinks to their own .pnpm folders.

When Node.js resolves require('express') from your project, it follows the symlink to .pnpm/express@4.18.2/node_modules/express. Inside express‘s code, when it calls require('body-parser'), Node.js starts resolution from express‘s real path (.pnpm/express@.../node_modules/express) and walks up to find .pnpm/express@.../node_modules/body-parser, which is a symlink to body-parser‘s real location. The resolution succeeds.

When your project tries to require('body-parser') directly, Node.js starts from the project root and looks for node_modules/body-parser. There is no such symlink because body-parser is not a direct dependency. The resolution fails. This is the strictness mechanism.


c. Hoisting, strictness, and the escape hatches

pnpm’s default layout is strict. However, the real world contains packages that import their own dependencies incorrectly (undeclared imports). To prevent a flood of failures, pnpm hoists all dependencies to a hidden location: node_modules/.pnpm/node_modules. This is not the same as npm hoisting:

  • node_modules/foo — only direct dependencies
  • node_modules/.pnpm/node_modules/bar — all packages (including transitive dependencies)

When your code requires bar, Node.js looks in node_modules/bar (not found), then node_modules/.pnpm/node_modules/bar (found). So the hidden hoisting makes undeclared imports work, but the root layout is still strict.

There are three settings that control this behavior:

SettingEffect
hoist=true (default)Hoist all packages to .pnpm/node_modules
hoist=falseNo hoisting; strict only
shamefully-hoist=trueHoist to root node_modules (npm-style)
node-linker=hoistedFlat layout; no symlinks

The shamefully-hoist and node-linker=hoisted settings are the compatibility escape hatches. They break pnpm’s strictness and disk efficiency, but they make every legacy tool work.

# Enable shameful hoisting
echo "shamefully-hoist=true" > .npmrc

# Or switch to a flat node_modules
echo "node-linker=hoisted" > .npmrc

# Reinstall
rm -rf node_modules pnpm-lock.yaml
pnpm install

Complete Example Session

# ============================================
# PART 1: INSTALL A DEPENDENCY
# ============================================

pnpm add express
# express is installed and linked.
# ============================================
# PART 2: INSPECT THE ROOT NODE_MODULES
# ============================================

ls -la node_modules/
# express is a symlink to .pnpm/express@.../node_modules/express
# ============================================
# PART 3: INSPECT THE VIRTUAL STORE
# ============================================

ls -la node_modules/.pnpm/
# Contains folders for express and all its dependencies.
# ============================================
# PART 4: INSPECT EXPRESS'S DEPENDENCIES
# ============================================

ls -la node_modules/.pnpm/express@4.18.2/node_modules/
# express/ (real files)
# body-parser -> ../../body-parser@.../node_modules/body-parser
# accepts -> ...
# ============================================
# PART 5: OBSERVE STRICTNESS
# ============================================

node -e "require('express')"     # ✅ Works
node -e "require('body-parser')" # ❌ Error: Cannot find module
# ============================================
# PART 6: INSPECT HIDDEN HOISTING
# ============================================

ls node_modules/.pnpm/node_modules/
# body-parser, accepts, and all transitive deps exist here.
# ============================================
# PART 7: ENABLE SHAMEFUL HOISTING
# ============================================

echo "shamefully-hoist=true" > .npmrc
rm -rf node_modules
pnpm install
node -e "require('body-parser')" # ✅ Works now
# ============================================
# PART 8: SWITCH TO FLAT LAYOUT
# ============================================

echo "node-linker=hoisted" > .npmrc
rm -rf node_modules
pnpm install
ls -la node_modules/express
# express is a real directory, not a symlink.
# ============================================
# PART 9: PRUNE THE STORE
# ============================================

pnpm store prune
# Removes packages no project currently uses.

The nine parts trace the layout: install, inspect root symlinks, inspect virtual store, inspect nested dependencies, observe strictness, inspect hidden hoisting, enable shameful hoisting, switch to flat layout, and prune the store.


Quick Reference

Directory Structure

PathContents
~/.local/share/pnpm/store/v10Global store (hard-link source)
node_modules/.pnpm/Virtual store (hard-linked files)
node_modules/.pnpm/<pkg>@<ver>/node_modules/<pkg>Real package files
node_modules/<pkg>Symlink to direct dependency
node_modules/.pnpm/node_modules/Hidden hoisting target

Link Types

TypeUse in pnpm
Hard linkStore files into .pnpm
SymlinkRoot node_modules to .pnpm, and nested deps

Hoisting Settings

SettingEffect
hoist=true (default)Hidden hoist in .pnpm/node_modules
hoist=falseNo hoisting; strict only
shamefully-hoist=trueHoist to root node_modules
node-linker=hoistedFlat layout (npm-style)

Best Practices

✅ Do This:

ls -la node_modules/express
<!-- Verify the symlink target -->                    # ✅
# Declare every direct dependency
<!-- Strictness catches missing declarations -->      # ✅
pnpm store prune
<!-- Free disk space from unreferenced packages -->   # ✅

❌ Don’t Do This:

# Import a transitive dependency directly
<!-- Fails under pnpm unless declared -->            # ❌
# Enable shamefully-hoist without investigating
<!-- Hides legitimate missing declarations -->       # ❌
# Delete .pnpm expecting a reinstall to restore it
<!-- Actually fine, but slow -->                      # ❌

Common Pitfalls

PitfallWhy It HappensFix
Tool cannot find moduleTool does not follow symlinksnode-linker=hoisted
Undeclared import failspnpm strict by defaultDeclare the dependency
Disk not saving spacedu counts hard links as separateUse du --apparent-size or check store size
pnpm store prune removes packagePackage not referencedReinstall the project
Symlink permission errorWindows symlink restrictionsEnable Developer Mode or use node-linker=hoisted

Real-World Examples

1. Verify Symlink

ls -la node_modules/express

2. Inspect Virtual Store

ls node_modules/.pnpm/

3. Check Nested Deps

ls node_modules/.pnpm/express@4.18.2/node_modules/

4. Hidden Hoisting

ls node_modules/.pnpm/node_modules/ | head

5. Strict Test

node -e "require('body-parser')"  # fails

6. Enable Shameful Hoisting

echo "shamefully-hoist=true" > .npmrc

7. Enable Flat Layout

echo "node-linker=hoisted" > .npmrc

8. Prune Store

pnpm store prune

9. Show Store Path

pnpm store path

10. Check Store Status

pnpm store status

Visual

Hard Links vs Symlinks

┌──────────────────────────────────────────────┐
│  GLOBAL STORE                                │
│                                              │
│  inode 1234: express/index.js                │
└──────────────────┬───────────────────────────┘
                   │
                   │ hard link (same inode)
                   ▼
┌──────────────────────────────────────────────┐
│  .pnpm/express@4.18.2/node_modules/express/  │
│  index.js (inode 1234)                       │
└──────────────────┬───────────────────────────┘
                   │
                   │ symlink (points to path)
                   ▼
┌──────────────────────────────────────────────┐
│  node_modules/express                        │
│  (symlink to .pnpm/.../express)              │
└──────────────────────────────────────────────┘

Hard links share data; symlinks point to paths.

Resolution Path

Project code: require('body-parser')
        │
        ▼
node_modules/body-parser (not found)
        │
        ▼
node_modules/.pnpm/node_modules/body-parser (found)
        │
        ▼
returns body-parser module

Hidden hoisting makes it work even though the root does not have a symlink.


Summary

ItemValue
Store locationpnpm store path
Virtual storenode_modules/.pnpm
Root node_modulesSymlinks to direct dependencies
Hidden hoistingnode_modules/.pnpm/node_modules
Hard link sourceGlobal store
Symlink target.pnpm folders
Compatibility fallbacknode-linker=hoisted
Strictness togglehoist=false (no hoist)

Key takeaways:

  • Files live in the store, not in the project. Hard links into .pnpm make them appear local without duplication.
  • Symlinks connect the root to direct dependencies. Transitive dependencies are symlinked inside .pnpm, not at the root.
  • Strictness is the default. Undeclared imports fail. This catches bugs that hoisting hides.
  • Hidden hoisting preserves compatibility. Broken packages can still resolve their undeclared dependencies through .pnpm/node_modules.
  • Escape hatches exist. shamefully-hoist=true and node-linker=hoisted sacrifice strictness for compatibility with legacy tools.

Remember: pnpm is not a filesystem of copied files. It is a graph of links. The store is the source, .pnpm is the virtual store, the root is a set of symlinks, and the hidden hoisting is the compatibility layer. Learn the graph, and the behavior becomes predictable.

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!