| | |

pnpm 01 📦 Introduction to pnpm and Content-Addressable Store Architecture

⏱️ Estimated read: 12 min | |

Every JavaScript package manager before pnpm solved the same problem in the same way: download packages, copy them into a node_modules folder, and repeat for every project. pnpm discards the copying entirely. It stores every file of every package exactly once on your disk in a global content-addressable store, then constructs a node_modules directory out of hard links and symbolic links that point back to that single source of truth. The result is a package manager that consumes a fraction of the disk space, installs faster on subsequent runs, and enforces a dependency tree strict enough to catch bugs that npm and Yarn Classic silently permit.

Key point: pnpm does not copy packages into your project. It links them from a global store. The node_modules you see is a graph of symlinks, and the files themselves exist only once on the entire disk.


Why pnpm exists

The disk duplication problem. If you have one hundred projects using the same version of React, npm and Yarn Classic store one hundred physical copies of React on your disk. pnpm stores React once in a content-addressable store. When a project needs React, pnpm creates hard links—filesystem entries that point to the same data blocks—inside the project’s .pnpm virtual store. The data is not duplicated. If a new version of React changes only one file out of a hundred, pnpm adds only that one new file to the store, not the entire package .

The ghost dependency problem. npm and Yarn Classic hoist dependencies to the root of node_modules to prevent excessively deep directory structures. This hoisting creates a side effect: your project can import a package it never declared, simply because one of its dependencies installed that package and hoisting made it visible. pnpm’s default layout is strict. The root node_modules contains symlinks only to the packages you explicitly declared in package.json. If you try to import an undeclared dependency, Node.js will not find it, because the symlink does not exist .

The installation speed problem. Traditional package managers perform three stages: resolve dependencies, fetch them, and write them to disk. pnpm performs three different stages: resolve dependencies, calculate the node_modules structure, and link dependencies from the store. The linking stage is dramatically faster than writing because hard links do not copy data. On a warm cache, pnpm can complete an install in a fraction of the time npm takes for the same project .

The workspace coupling problem. In a monorepo managed by npm or Yarn Classic, hoisting means that a workspace can accidentally use a dependency declared only by a sibling workspace. The dependency exists in the hoisted root, so the import succeeds locally but fails in production or when the sibling is removed. pnpm’s isolated layout prevents this. Each workspace sees only its own declared dependencies, plus the dependencies of the packages it depends on through symlinks, which is exactly what Node.js resolution would produce if the package were installed in isolation .

The trade-off. pnpm’s strictness is also its compatibility risk. Tools that assume a flat node_modules directory—some older bundlers, test runners, and IDE plugins—may fail under pnpm’s symlinked layout. pnpm provides a nodeLinker: hoisted setting in .npmrc to fall back to a traditional npm-like structure when compatibility demands it .


a. The content-addressable store

The content-addressable store is pnpm’s foundation. It is a global directory, typically located outside any project, that holds every file from every package version pnpm has ever downloaded. The store is content-addressable because files are named by their content hash, not by package name or version. If two versions of a package share identical files, those files are stored once. If a new version changes a single file, only that file is added .

The store is not a package cache in the npm sense. It does not hold tarballs. It holds unpacked files, ready to be linked. When pnpm resolves a dependency, it checks the store for the required files. If they exist, it links them. If they do not, it fetches the package, extracts the missing files into the store, and then links them. The store is shared across all projects on the machine, which is why the second project to use a dependency installs it instantly .

The location of the store is configurable, but the default on Linux is under the user’s home directory (e.g., ~/.local/share/pnpm/store). The store can grow large over time as new package versions are added, but it never contains duplicate file contents. Running pnpm store prune removes unreferenced packages that no project on the machine currently depends on.


b. The .pnpm virtual store and symlink construction

The node_modules directory that pnpm creates is not where the actual files live. The real files are hard-linked into a hidden directory called node_modules/.pnpm. This directory is the virtual store, and its layout mirrors the dependency graph. Each package version gets its own folder, named with the package name and version (e.g., foo@1.0.0), and inside that folder is a node_modules subdirectory containing hard links to the package’s files .

node_modules/
├── .pnpm/
│   ├── foo@1.0.0/
│   │   └── node_modules/
│   │       └── foo/
│   │           ├── index.js      ← hard link to store
│   │           └── package.json  ← hard link to store
│   └── bar@1.0.0/
│       └── node_modules/
│           └── bar/
│               └── index.js      ← hard link to store
└── foo -> .pnpm/foo@1.0.0/node_modules/foo

The root node_modules contains symbolic links to the packages you directly declared. In the example above, foo is a direct dependency, so node_modules/foo is a symlink to .pnpm/foo@1.0.0/node_modules/foo. When your code imports foo, Node.js follows the symlink to the real files. If foo depends on bar, pnpm creates a symlink from foo‘s virtual store folder to bar‘s virtual store folder, so that foo can resolve bar without bar being visible at the root .

This nested structure is what enforces strictness. bar is not symlinked into the root node_modules, so your project code cannot import bar unless you declared it. But foo can import bar because the symlink exists inside foo‘s isolated folder. Node.js resolution follows symlinks to their real locations, so the actual resolution path is .pnpm/foo@1.0.0/node_modules/bar, which is exactly where bar lives .


c. Strictness, hoisting, and the compatibility escape hatch

pnpm’s default strictness is the feature that distinguishes it most sharply from npm. The root node_modules contains only your declared dependencies. Every transitive dependency is isolated inside the virtual store, accessible only to the package that declared it. This means a package that imports a dependency it forgot to declare will fail under pnpm, even if that dependency is installed elsewhere in the tree .

The real world contains many broken packages that rely on hoisting. To prevent a flood of failures, pnpm hoists all dependencies by default into a hidden location: node_modules/.pnpm/node_modules. This is not the same as npm’s hoisting. It is a compatibility layer that allows broken packages to resolve their undeclared dependencies, but it does not make those dependencies visible to your project’s code. Your code still cannot import bar unless you declared it, but foo can import bar even if foo forgot to declare it, because bar exists in the hidden hoisted location .

This hoisting can be disabled entirely by setting hoist=false in .npmrc. For projects that prioritize absolute strictness over compatibility with broken packages, this is the recommended setting. For projects migrating from npm, leaving the default hoisting enabled reduces the number of immediate failures.

When a tool fundamentally cannot work with symlinks—a situation that is increasingly rare but still occurs with some legacy tooling—pnpm offers nodeLinker=hoisted. This setting produces a node_modules directory that looks and behaves like npm’s: flat, copied, and permissive. It sacrifices pnpm’s disk efficiency and strictness, but it guarantees that any tool expecting a traditional layout will function .


Complete Example Session

# ============================================
# PART 1: INSTALL PNPM
# ============================================

corepack enable
corepack prepare pnpm@latest --activate
# pnpm is now available as a command.
# ============================================
# PART 2: INITIALIZE A PROJECT
# ============================================

mkdir my-project && cd my-project
pnpm init
# Creates package.json.
# ============================================
# PART 3: INSTALL A DEPENDENCY
# ============================================

pnpm add express
# express is fetched, extracted to the global store,
# and hard-linked into node_modules/.pnpm.
# ============================================
# PART 4: INSPECT THE LAYOUT
# ============================================

ls -la node_modules/
# express is a symlink to .pnpm/express@.../node_modules/express
# .pnpm/ contains the real hard-linked files.
# ============================================
# PART 5: OBSERVE STRICTNESS
# ============================================

# express depends on many packages (body-parser, etc.)
# Try to import one of them directly:
node -e "require('body-parser')"
# Error: Cannot find module 'body-parser'
# It is installed, but not symlinked to the root.
# ============================================
# PART 6: CHECK DISK USAGE
# ============================================

du -sh node_modules/
# The apparent size includes hard-linked files.
# The actual disk usage is much smaller because
# files are shared with the global store.
# ============================================
# PART 7: SECOND PROJECT USES SAME PACKAGE
# ============================================

mkdir ../second-project && cd ../second-project
pnpm init
pnpm add express
# Install is nearly instant.
# express files are already in the global store.
# ============================================
# PART 8: FALL BACK TO HOISTED LAYOUT
# ============================================

# If a tool requires a flat node_modules:
echo "node-linker=hoisted" > .npmrc
rm -rf node_modules
pnpm install
# node_modules is now npm-style.
# ============================================
# PART 9: VERIFY STORE LOCATION
# ============================================

pnpm store path
# Prints the global store directory.
# Every project on this machine links from here.

The nine parts trace the pnpm lifecycle: installing the tool, initializing a project, adding a dependency, inspecting the symlinked layout, observing the strictness that blocks undeclared imports, checking disk usage, demonstrating store sharing across projects, falling back to a hoisted layout for compatibility, and locating the global store.


Quick Reference

pnpm Commands (Core)

CommandPurpose
pnpm add <pkg>Add a dependency
pnpm remove <pkg>Remove a dependency
pnpm installInstall all dependencies
pnpm store pathShow global store location
pnpm store pruneRemove unreferenced packages

Store and Layout Settings

SettingLocationEffect
store-dir.npmrcCustom store location
node-linker.npmrcisolated (default), hoisted
hoist.npmrctrue (default), false

Key Concepts

ConceptMeaning
Content-addressable storeGlobal file cache keyed by content hash
Virtual store (.pnpm)Where hard links to store files live
SymlinkRoot node_modules entries pointing to virtual store
Ghost dependencyUndeclared import that works under hoisting

Best Practices

✅ Do This:

pnpm store path
<!-- Know where the global store lives -->            # ✅
# Declare every dependency you import
<!-- pnpm will fail if you forget -->                 # ✅
node-linker=hoisted
<!-- Use only when a tool requires a flat layout -->  # ✅

❌ Don’t Do This:

# Assume node_modules contains copied files
<!-- It contains symlinks to .pnpm -->               # ❌
# Delete the global store expecting projects to break
<!-- Projects re-link on next install -->            # ❌
# Mix pnpm and npm on the same project
<!-- Lockfiles and layouts are incompatible -->      # ❌

Common Pitfalls

PitfallWhy It HappensFix
Tool fails on symlinksTool assumes flat node_modulesnode-linker=hoisted in .npmrc
Undeclared import failspnpm strict by defaultDeclare the dependency explicitly
Disk space not reduceddu counts hard links as separateUse du -sh --apparent-size carefully; actual usage is shared
Slow install after store prunePackages re-fetchedReinstall warms the store again
Workspace dependency not foundNot declared in workspace package.jsonAdd it with workspace:*

Real-World Examples

1. Install pnpm

corepack enable
corepack prepare pnpm@latest --activate

2. Add Dependency

pnpm add express

3. Inspect Symlink

ls -la node_modules/express
# symlink to .pnpm/express@.../node_modules/express

4. Check Store Path

pnpm store path

5. Prune Store

pnpm store prune

6. Hoisted Fallback

node-linker=hoisted

7. Disable Hoisting

hoist=false

8. Second Project Speed

cd ../another-project
pnpm add express
# Nearly instant; files already in store

9. Verify Strictness

node -e "require('body-parser')"
# Error: Cannot find module

10. Check Actual Disk Usage

du -sh node_modules/
# Includes hard links; actual usage is lower

Visual

pnpm Store and Project Layout

┌──────────────────────────────────────────────┐
│  GLOBAL CONTENT-ADDRESSABLE STORE            │
│  (~/.local/share/pnpm/store)                 │
│                                              │
│  File A (hash: abc123)                       │
│  File B (hash: def456)                       │
│  File C (hash: ghi789)                       │
└──────────────────┬───────────────────────────┘
                   │
                   │ hard links
                   ▼
┌──────────────────────────────────────────────┐
│  PROJECT A                                   │
│  node_modules/.pnpm/express@4.18.2/          │
│  node_modules/.pnpm/express@4.18.2/...       │
│                                              │
│  node_modules/express -> .pnpm/...           │
└──────────────────────────────────────────────┘
                   │
                   │ hard links (same files)
                   ▼
┌──────────────────────────────────────────────┐
│  PROJECT B                                   │
│  node_modules/.pnpm/express@4.18.2/          │
│                                              │
│  node_modules/express -> .pnpm/...           │
└──────────────────────────────────────────────┘

The files exist once. Both projects link to them.

Symlink Resolution

Project code: import 'express'
        │
        ▼
node_modules/express (symlink)
        │
        ▼
.pnpm/express@4.18.2/node_modules/express (real files)
        │
        ▼
Store files (hard links)

Node.js follows the symlink to the real location.


Summary

ItemValue
Store typeContent-addressable, global
Linking methodHard links to store, symlinks to root
Virtual storenode_modules/.pnpm
Root node_modulesSymlinks to direct dependencies only
Ghost dependency protectionDefault (isolated)
Compatibility fallbacknode-linker=hoisted
Hoisting of broken packagesHidden in .pnpm/node_modules
Install speedFaster on warm cache

Key takeaways:

  • pnpm stores files once globally. Every project links to the same store via hard links. Disk space scales with unique file content, not with the number of projects .
  • The .pnpm virtual store holds the real files. The root node_modules contains symlinks to direct dependencies only. Transitive dependencies are isolated inside .pnpm .
  • Strictness is the default. Undeclared imports fail. This catches bugs that hoisting hides, but requires every dependency to be declared explicitly.
  • Hoisting still exists, but it is hidden. Broken packages that rely on undeclared dependencies can still resolve them through .pnpm/node_modules, but your code cannot see those packages.
  • node-linker=hoisted is the escape hatch. When a tool absolutely requires a flat layout, this setting produces an npm-like node_modules at the cost of pnpm’s efficiency and strictness .

Remember: pnpm is not a faster npm. It is a different model of installation. The store is the source of truth; the node_modules is a graph of links. The strictness is deliberate, and the disk savings are real. Learn the layout, and the tool’s behavior becomes predictable. Fight the layout, and the symlinks become a source of confusion.

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!