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 in 2019, after writing about why node_modules folders get so big. I had a 512GB SSD on the MacBook Pro I bought in 2010, but some brand new computers in 2019 shipped with a 128GB SSD (something went wrong with Moore’s Law when it comes to hard disk space).

Every byte saved on disk can be used for something else than library code. That’s still the main idea behind pnpm, but teams also pick it because it’s fast and strict about dependencies, and because it handles monorepos well. 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, while pnpm links them.

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

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/[email protected]/node_modules/express
└── .pnpm/
    ├── [email protected]/
    └── [email protected]/

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

With pnpm, your code can only import the packages your package.json lists.

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 bug is in that package, but you don’t have to wait for a fix. The easiest workaround, from pnpm’s FAQ, is to add the missing package to your own project with pnpm add. It lands at the top of your node_modules, and the package that needs it finds it there.

You can also 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

Use it when a tool doesn’t work well with symlinks. You lose the strictness, though.

Build scripts don’t run

Some packages run a script after install. esbuild, for example, uses one to set up its native binary.

Since pnpm 10, pnpm doesn’t run those scripts for your dependencies unless you approve them. It’s a security feature, so a compromised package can’t run code on your machine 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?

Yes, definitely use pnpm.

Installs are faster, and every package version lives once on your disk no matter how many projects use it. The strict node_modules is a plus too: a missing dependency breaks on your machine, not in production.

The only thing npm has over it is that it ships with Node, so it’s always there. Installing pnpm is one command, so that’s not much of an argument.

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: