What is pnpm?

By

pnpm is a fast npm alternative that stores packages once and links them into projects. Learn how it works, its strict node_modules, lockfile and gotchas.

~~~

pnpm is a package manager for Node.js, an alternative to npm. It installs the same packages from the same registry, but it stores each version of a package once on your computer and links it into every project that needs it.

I first looked at it after writing about why node_modules folders get so big. Back in 2019 plenty of new laptops shipped with a 128GB SSD, and having the same libraries copied into dozens of projects hurt.

Disk space is still the headline feature, but it’s not the only reason teams use pnpm today. It’s fast, it’s strict about dependencies, and it has great support for monorepos. You can check it out at https://pnpm.io.

How pnpm saves space

With npm, every project gets its own full copy of every package in its node_modules folder.

pnpm keeps one global content-addressable store. Every file of every package version lives there once. When you install a package in a project, pnpm creates a hard link from the store into that project’s node_modules, instead of copying the files.

If you have 10 projects that use React at the same version, React sits on your disk once, and all 10 projects point to it.

This also makes installs faster. Even when npm has a package in its cache, it copies the files into your project. pnpm just links them, which is much quicker.

Installing pnpm

The recommended way to install pnpm is the standalone script (works even without Node already installed):

curl -fsSL https://get.pnpm.io/install.sh | sh -

You can also install it with npm (this pulls the npm latest line, currently pnpm 12):

npm install -g pnpm

Check it worked:

pnpm --version

Using pnpm

The commands mirror the npm ones you already know:

pnpm install react
pnpm update react
pnpm uninstall react

and so on. pnpm add react is the more common way to add a dependency, and pnpm remove react removes it.

Running scripts from package.json works the same way too, and you can even skip run:

pnpm dev
pnpm build

If you use npx, which is a handy way to run one-off utilities without installing them globally, you’ll get the benefits of pnpm by using pnpm dlx (or the older pnpx alias).

Scaffolding a new project works the same way. For example this creates a new Vite app:

pnpm create vite my-cool-new-app

A stricter node_modules

This is the part that surprises people coming from npm.

npm flattens your dependencies. Say your project depends on express, and express depends on debug. npm puts both at the top level of node_modules:

node_modules/
├── express
├── debug
└── ...

Because debug sits at the top level, your code can import it, even though it’s not in your package.json:

import debug from 'debug'

It works, by accident. This is called a phantom dependency. The day express stops using debug, or bumps it to a new major version, your code breaks and you have no idea why.

pnpm doesn’t do this. Only the packages you listed in package.json show up at the top level of node_modules. Everything else lives in a hidden node_modules/.pnpm folder, linked together with symlinks:

node_modules/
├── express -> .pnpm/express@5.1.0/node_modules/express
└── .pnpm/
    ├── express@5.1.0/
    └── debug@4.4.1/

express can still find debug, because pnpm links it where express expects it. But your code can’t. Run the same import and Node throws an error:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'debug'

The fix is to add it as a real dependency:

pnpm add debug

This strictness is a big reason teams switch. What your package.json says is what your code can actually use.

The lockfile

npm writes a package-lock.json file. pnpm writes pnpm-lock.yaml instead.

It does the same job: it records the exact version of every package in the tree, so everyone on the team and your CI server install exactly the same thing. Commit it to Git.

If you’re moving an existing project from npm, you can generate a pnpm lockfile from the npm one, so you keep the same versions:

pnpm import

Then delete package-lock.json and node_modules, and run pnpm install. Don’t keep both lockfiles around, or people will use different package managers on the same project.

Common gotchas

A package assumes hoisting. Some older packages use a dependency they never declared in their own package.json. With npm this works thanks to flattening. With pnpm you get a “Cannot find module” error coming from inside node_modules.

The clean fix is to tell pnpm to hoist that specific package to the top level. Settings go in pnpm-workspace.yaml in the root of your project:

publicHoistPattern:
  - '*eslint*'

As a last resort you can ask pnpm to build a flat, npm-style node_modules:

nodeLinker: hoisted

This gets tools like React Native or some bundlers working when nothing else helps, but you lose the strictness.

Build scripts don’t run. Some packages run a script after install, for example esbuild downloads its native binary. Since pnpm 10, pnpm doesn’t run those scripts for your dependencies unless you approve them. It’s a security feature: a compromised package can’t run code on your machine just because you installed it.

When pnpm skips a build it tells you, and you can approve the packages you trust:

pnpm approve-builds

Your choices are saved under allowBuilds in pnpm-workspace.yaml, so the rest of the team gets them too.

Monorepos and many projects

pnpm is especially appreciated where you maintain many projects with the same dependencies. Glitch was an early example, back when it hosted a gazillion Node.js projects.

pnpm workspaces let you keep several packages in one repository and install them all at once. You list them in pnpm-workspace.yaml:

packages:
  - 'packages/*'

Then the -r flag (short for recursive) runs a command in every package:

pnpm -r build

Where are the packages stored?

You can ask pnpm directly:

pnpm store path

On macOS the store lives in ~/Library/pnpm/store, and on Linux in ~/.local/share/pnpm/store.

When I first tried pnpm in 2019 it used ~/.pnpm-store/ instead. I installed lodash as an example and this was the resulting folder structure:

➜  ~ tree .pnpm-store/
.pnpm-store/
└── 2
    ├── _locks
    ├── registry.npmjs.org
    │   └── lodash
    │       ├── 4.17.11
    │       │   ├── integrity.json
    │       │   ├── node_modules
    │       │   │   └── lodash
    │       │   │       ├── ...
    │       │   ├── package -> node_modules/lodash
    │       │   └── packed.tgz
    │       └── index.json
    └── store.json

The layout inside has changed since then, but the idea is the same: one copy of each package, shared by every project.

The store only grows over time. To remove packages no project references anymore, run:

pnpm store prune

Should you use pnpm or npm?

For a new project, I think pnpm is a great default. It’s fast, it saves disk space, and the strict node_modules catches missing dependencies early instead of in production.

Stick with npm when a project already uses it and works fine, when your team or hosting platform only supports npm, or when you’re following a tutorial and don’t want an extra variable. npm ships with Node, so it’s always there.

In an existing project, follow whatever lockfile is in the repository. If you see pnpm-lock.yaml, use pnpm. If you see package-lock.json, use npm.

Tagged: Node.js · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about node: