Skip to main content

pnpm: the package manager that said "why are we copying lodash 500 times?"

· 8 min read
Pere Pages
Software Engineer

pnpm is a JavaScript package manager, like npm and Yarn, but with a very opinionated design: fast installs, less disk usage, stricter dependency resolution, and strong monorepo support. Its name originally stands for “performant npm”. It reached v1 in 2017, after earlier work starting in 2016, and it was heavily inspired by ideas from ied, another experimental package manager. (Medium)

pnpm logo

The problem pnpm tried to solve

Classic npm installs dependencies into each project’s node_modules.

So if you have:

project-a -> lodash
project-b -> lodash
project-c -> lodash

You may end up with many physical copies of the same package.

pnpm said: store packages once globally, then link them into projects.

Its official pitch is basically:

fast, disk-space efficient, good for monorepos. (pnpm)

The core idea

pnpm uses a content-addressable store.

That means downloaded package files are stored once, based on their content. Project node_modules folders then use links to that store instead of duplicating everything. The pnpm GitHub docs explicitly describe this design: if 100 projects use lodash, pnpm stores it once rather than copying it 100 times. (GitHub)

Conceptually:

~/.pnpm-store
react@19
typescript@5
lodash@4

my-app/node_modules
react -> link to store
typescript -> link to store

Result:

less disk usage
faster installs
less duplicated package garbage

Why pnpm feels stricter than npm

This is one of the most important differences.

With old-style flattened node_modules, your app can sometimes import packages that you did not declare in package.json.

Example:

import leftPad from "left-pad";

Maybe left-pad works only because some other dependency installed it transitively.

That is bad because your project depends on something it did not declare.

pnpm’s node_modules layout is stricter. In practice, this catches “phantom dependencies” earlier.

Good:

{
"dependencies": {
"left-pad": "^1.3.0"
}
}

Bad:

{
"dependencies": {
"some-package-that-happens-to-use-left-pad": "^1.0.0"
}
}

Then importing left-pad directly from your app is dishonest.

pnpm vs npm vs Yarn

npmYarnpnpm
In shortDefault, official, universally supportedBorn to fix npm’s old speed/reliability problemsContent-addressable store + symlinks: fast, strict, disk-efficient
Good forSimple projects, maximum compatibility, zero tooling debateLarge projects, workspaces, alternative install strategiesMonorepos, CI speed, strict dependency hygiene, saving disk, modern JS/TS projects
WeaknessHistorically slower, less strict, less disk-efficient than pnpmYarn Classic vs Berry confusion, PnP can be disruptive, ecosystem assumptions sometimes breakSome tools assume npm-style flat node_modules, occasional compatibility fixes needed, newcomers may be confused by linking/store behavior

Common use cases

1. Normal app development

pnpm install
pnpm dev
pnpm build
pnpm test

Basically same daily flow as npm.

2. Monorepos

This is where pnpm shines.

A workspace is defined with:

# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"

pnpm workspaces let you manage multiple packages in one repo. The official docs say a workspace must have a pnpm-workspace.yaml file at the root. (pnpm)

Typical structure:

repo/
apps/
web/
mobile/
packages/
ui/
config/
api-client/
pnpm-workspace.yaml
package.json

Then packages can depend on each other:

{
"dependencies": {
"@acme/ui": "workspace:*"
}
}

3. CI pipelines

pnpm is useful in CI because:

installs are fast
cacheable global store
deterministic lockfile
good workspace filtering

Example GitHub Actions-ish command:

pnpm install --frozen-lockfile
pnpm --filter @acme/web build
pnpm --filter @acme/web test

4. Frontend monorepos

Very common with:

React
Next.js
Vite
Expo
Turborepo
Nx
shared UI packages
shared TypeScript configs
shared ESLint configs

The lockfile

pnpm uses:

pnpm-lock.yaml

Commit it.

It gives deterministic installs:

pnpm install --frozen-lockfile

In a serious project, the rule is simple:

package.json changes -> pnpm-lock.yaml changes too

packageManager field

Modern projects often pin pnpm in package.json:

{
"packageManager": "pnpm@10.12.1"
}

This helps all developers and CI use the same package manager version.

Usually with Corepack:

corepack enable
corepack prepare pnpm@latest --activate

Workspaces: the killer feature

Example:

packages:
- "apps/*"
- "packages/*"

Then:

pnpm --filter web dev
pnpm --filter api test
pnpm --filter "./packages/*" build

This is perfect for:

one frontend app
one backend app
shared UI package
shared types package
shared eslint/tsconfig package

Example:

apps/web
apps/admin
packages/ui
packages/types
packages/config

Catalogs: newer monorepo feature

pnpm catalogs let you define dependency versions centrally in pnpm-workspace.yaml, then reference them from packages. Official docs describe catalogs as reusable constants for dependency version ranges. (pnpm)

Example:

catalog:
react: ^19.0.0
typescript: ^5.8.0

Then in package.json:

{
"dependencies": {
"react": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
}
}

Why useful?

fewer version mismatches
less dependency drift
cleaner monorepo upgrades
fewer merge conflicts

Strengths

1. Fast installs

pnpm is optimized for install speed. This matters a lot in CI and large repos. (pnpm)

2. Saves disk space

The content-addressable store avoids repeated physical copies. (GitHub)

3. Strict dependency model

It catches undeclared dependency usage better than classic flattened node_modules.

4. Excellent monorepo support

Workspaces, filters, workspace:*, catalogs.

5. Mostly npm-compatible UX

Many commands feel familiar:

npm install -> pnpm install
npm run build -> pnpm build
npm exec foo -> pnpm exec foo
npx foo -> pnpm dlx foo

6. Better dependency hygiene

It makes hidden dependency coupling more visible.

Weaknesses

1. Some packages assume flat node_modules

Most modern packages are fine, but old/badly-written tools may assume dependencies are hoisted.

Symptom:

Cannot find module X

Fix may be:

pnpm add X

Or configure hoisting in .npmrc, but use that carefully.

2. Slight learning curve

The store, symlinks, workspace filters, and strictness can confuse people coming from npm.

3. Tooling compatibility is not always perfect

Rare today, but it still happens with older CLIs, React Native setups, native modules, or badly packaged libraries.

4. Another package manager decision

In teams, “why not just npm?” is a real social/maintenance question.

Important config files

package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc

Example .npmrc:

strict-peer-dependencies=false
auto-install-peers=true

Use config intentionally. Don’t cargo-cult .npmrc.

Peer dependencies

Peer deps are dependencies expected to be provided by the consumer.

Example:

{
"peerDependencies": {
"react": "^18 || ^19"
}
}

Common in libraries.

pnpm can be stricter about peer issues, which is good but sometimes noisy.

Typical warning:

unmet peer dependency react@...

Meaning:

this package expects a compatible react version

Don’t ignore blindly. But don’t panic either.

The mental model

Think of pnpm as:

npm-compatible interface
+
shared package store
+
symlinked node_modules
+
strict dependency graph
+
workspace-native tooling

That’s it.

When I would choose pnpm

Use pnpm for:

React app
Next.js app
Vite app
TypeScript project
monorepo
CI-heavy project
shared packages
frontend platform work

Especially if you have:

apps/web
apps/mobile
packages/ui
packages/tokens
packages/types

That is pnpm territory.

When I might avoid pnpm

Avoid or be cautious if:

legacy project with weird build tools
old React Native setup
team strongly standardized on npm
vendor tooling explicitly supports only npm/yarn

Not because pnpm is bad. Because friction costs time.

Compact command cheat sheet

# install deps
pnpm install

# add deps
pnpm add react
pnpm add -D typescript
pnpm add -w -D eslint

# remove
pnpm remove lodash

# run scripts
pnpm dev
pnpm build
pnpm test
pnpm run lint

# execute local binary
pnpm exec eslint .

# run temporary package
pnpm dlx create-vite

# update
pnpm update
pnpm update --latest
pnpm update --interactive

# inspect
pnpm list
pnpm why react

# workspaces
pnpm --filter @acme/web build
pnpm --filter @acme/ui test
pnpm --filter "./packages/*" build

# CI
pnpm install --frozen-lockfile

# store
pnpm store path
pnpm store prune

For a fuller, always-updated reference, see pnpm Most Important Commands in the Resources section.

Final opinion

pnpm is not just “faster npm”.

Its real value is this:

It makes dependency management more honest.

You declare what you use. You avoid duplicated packages. You get better monorepo ergonomics. You get faster installs.

For modern TypeScript/React work, especially monorepos, pnpm is probably the package manager I’d pick by default.

Tip

In a monorepo, start with these three rules:

1. Always commit pnpm-lock.yaml.
2. Use workspace:* for internal packages.
3. Use pnpm --filter for CI jobs.