npm vs pnpm in 2026: Speed, Workspaces, and the Supply Chain Case for Switching

npm vs pnpm in 2026: official benchmark data including the Rust-based pnpm 12, workspace workflows, and the supply chain case for switching, from blocked build scripts and minimum release age to git hooks and editor tooling.

npm vs pnpm in 2026: Speed, Workspaces, and the Supply Chain Case for Switching

If you have written any Node.js in the last few years, you have probably had the same conversation: npm ships with Node, so why bother with pnpm? I have run enough projects through both to give a different answer now.

The package manager debate stopped being about taste somewhere around the Shai-Hulud attacks and the pnpm 12 rewrite. It is now about how much of your dependency graph you are willing to let execute code on your machine without asking, and how long your installs take.

This post is the sysadmin's-eye comparison: how pnpm actually stores and links packages, what the official benchmark numbers say including the Rust-based pnpm 12, how workspaces compare, and the part most comparisons skip, the supply chain story in depth, including the git hooks and editor tooling that quietly turn your dev environment into part of the attack surface.

The short version

  • npm is the default that ships with Node; pnpm is a drop-in replacement with a different storage model. npm hoists a flat node_modules tree; pnpm keeps one content-addressable store on disk and links packages into place with symlinks and hard links. That one architectural choice drives most of the speed, disk, and safety differences below.
  • The official benchmark favors pnpm, and the gap grows with the Rust engine. On pnpm's own lots-of-files workload (measured Aug 12, 2026), a clean install took npm 27.3s, pnpm 7.1s, and the pnpm 12 Rust engine 2.0s. With everything warm, npm took 1.0s, pnpm 385ms, and the Rust engine 13ms.
  • pnpm treats install-time code execution as a threat. Since pnpm v10, dependency postinstall scripts are blocked by default and run only after you approve them (pnpm approve-builds / allowBuilds). pnpm v11 adds a 24-hour minimum release age by default, a block on exotic transitive dependencies, and a no-downgrade trust policy.
  • pnpm 12 is a Rust rewrite, currently a release candidate. It keeps pnpm 11's commands, flags, settings, and lockfile format; the announcement lists exactly three behavioral differences, led by project-aware global bins.
  • Workspaces are first-class. pnpm-workspace.yaml plus --filter, -r, and --parallel cover the same ground as npm workspaces with fewer surprises and much faster installs.
Verified
  • pnpm11.x (current stable)
  • pnpm12 RC (next-12 tag)
  • npmbundled with Node LTS
  • pnpm benchmarkrun Aug 12, 2026 (daily updated)

Checked 2026-08-12 against the pnpm 12 announcement (pnpm.io/blog/whats-different-in-pnpm-12), the official benchmarks page (pnpm.io/benchmarks), and the supply chain security guide (pnpm.io/supply-chain-security).

What each tool actually is

npm: the bundled default with a flat tree

npm is a client/server pair: the CLI talks to the npm registry over HTTPS, resolves the dependency graph from package-lock.json, and writes a flat, hoisted node_modules tree. Every package that can be hoisted goes to the top level, which is why you can sometimes import something you never declared; this is the phantom dependency problem. npm runs the preinstall, install, and postinstall scripts of every dependency by default, and it has shipped like that for over a decade.

pnpm: a content-addressable store with a symlinked tree

pnpm keeps one global store, usually under ~/.local/share/pnpm/store, keyed by content. Each package is stored once, hard-linked into your project's node_modules, and the dependency layout is created with symlinks. The result is a strict tree: you can only import what you declared, because the only things at the top level of node_modules are your direct dependencies and their actual links. Phantom imports fail instead of working by accident, and disk usage drops dramatically on monorepos.

Speed: what the real data says

The pnpm project maintains an official benchmark page that is re-run daily. The latest run at the time of writing was Aug 12, 2026, on a workload the page calls lots of files, comparing npm, pnpm, Yarn Classic, and Yarn PnP, plus the new Rust-based install engine that pnpm 12 uses (the project calls it pacquet). These are vendor-published numbers, so treat them as a floor you should verify on your own repository, but they match what I have seen switching real projects.

Install time, official pnpm benchmark (lots of files, Aug 12 2026)
npm, clean install: 27.3 snpm, clean install27.3 spnpm 11, clean install: 7.1 spnpm 11, clean inst…7.1 spnpm 12 (Rust), clean install: 2 spnpm 12 (Rust), cle…2 snpm, cache + lockfile: 6.6 snpm, cache + lockfi…6.6 spnpm 11, cache + lockfile: 1.9 spnpm 11, cache + lo…1.9 spnpm 12 (Rust), cache + lockfile: 0.739 spnpm 12 (Rust), cac…0.739 snpm, everything warm: 1 snpm, everything warm1 spnpm 11, everything warm: 0.385 spnpm 11, everything…0.385 spnpm 12 (Rust), everything warm: 0.013 spnpm 12 (Rust), eve…0.013 snpm, update: 6.3 snpm, update6.3 spnpm 11, update: 6.7 spnpm 11, update6.7 spnpm 12 (Rust), update: 1 spnpm 12 (Rust), upd…1 s
  • npm, clean install: no cache, no lockfile, no node_modules
  • pnpm 11, clean install: about 3.8x faster than npm
  • pnpm 12 (Rust), clean install: about 14x faster than npm
  • npm, cache + lockfile: developer reinstalling a known project
  • pnpm 12 (Rust), cache + lockfile: 739 ms
  • npm, everything warm: cache, lockfile, node_modules all present
  • pnpm 12 (Rust), everything warm: 13 ms
  • npm, update: dependency versions bumped, install re-run
  • pnpm 11, update: honest caveat: pnpm 11 was still slightly slower than npm here
  • pnpm 12 (Rust), update: about 7x faster than pnpm 11

Three honest observations about these numbers. First, the clean-install gap is the headline: 27.3s to 2.0s is the difference between waiting and not waiting. Second, the warm-path numbers are where the Rust engine really shows up: 385ms down to 13ms when nothing changed is the kind of thing you feel on every branch switch. Third, the update row is a reminder not to cherry-pick: pnpm 11 was actually slower than npm on a plain dependency bump, and the Rust rewrite is what fixed that specific case, 6.7s down to 1.0s.

For context, Yarn Classic clocked 6.9s on the clean install and Yarn PnP 2.9s, so pnpm 11 was already ahead of the field before pnpm 12, and the Rust engine pulls further away.

Workspaces: where the difference compounds

A single package.json hides the real win. In a monorepo, npm installs and rebuilds the whole tree with every change; pnpm's shared store means a dependency installed in one workspace package is a hard link, not a copy, in every other one. The config lives in pnpm-workspace.yaml at the repo root:

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

Then the everyday commands:

workspace commands
pnpm -w add typescript -D        # install at the workspace root
pnpm add lodash --filter @app/web # one package only
pnpm -r run test                 # every workspace package
pnpm -r --parallel run build     # all at once
pnpm --filter @app/web run build  # one package's script

The -r recursion and --filter targeting are the two features I miss most when I have to touch an npm-only repo. Combined with --parallel for builds, it covers the monorepo workflow without extra tooling.

Supply chain, in depth

This is the part that changed my mind, so it gets the most space.

The attack surface is bigger than postinstall

The classic mental model is malware runs at install time via postinstall scripts, and that is true but incomplete. A modern dev environment executes third-party code in at least five places:

  • Install-time lifecycle scripts. npm runs preinstall, install, and postinstall for every dependency by default. This is how the majority of npm supply chain attacks have executed historically.
  • Build-time native tooling. node-gyp evaluates binding.gyp files, and gyp supports <!() command expansion. In June 2026 a self-propagating worm used exactly that: a weaponized binding.gyp with a <! expression that ran attacker code during node-gyp configure, with no postinstall script and no gypfile flag for script-scanning tools to flag. The package looked like a normal native build.
  • Git hooks. Tools like husky install hooks via a prepare script, and that is a legitimate pattern. It is also a persistence vector: a compromised package, or a compromised version of a hook-installing tool, can install a hook that runs with your git identity on every commit. Whichever location the hooks live in, it is invisible to git diff, which makes it easy to miss in review.
  • Editor and IDE tooling. VS Code runs tasks.json and launch.json preLaunchTask entries from the repo, and extensions.json recommends extensions that can auto-update. ESLint, Prettier, and language server plugins execute arbitrary code inside your editor, which means a malicious package or plugin config runs as you. JetBrains run configurations do the same. The editor is a code-execution environment, not a viewer.
  • CI workflow injection. The June 2026 node-gyp worm went one step further: it wrote GitHub Actions workflow files into compromised repositories for persistence. Your CI secrets are inside the attack surface.

The common thread: these are all just files in the repo that execute with your credentials, and none of them are visible in git diff the way a code change is. My writeup on Docker Sandboxes for AI agents makes the same point about package.json scripts and .git/hooks being execution channels that review tools miss.

The 2025-2026 record

  • Shai-Hulud (September 2025). A coordinated campaign that infected well over 500 npm packages via compromised maintainer accounts, using post-install scripts as the primary delivery mechanism, harvesting credentials and spreading to other projects.
  • node-gyp supply chain compromise (June 2026). 57 packages, hundreds of malicious versions, critical severity. Novel because it bypassed script-focused detection entirely through binding.gyp command expansion, harvested credentials across npm, GitHub, and major cloud providers, injected GitHub Actions workflows, and self-propagated through maintainer accounts.

The older classics are worth remembering too: eslint-scope in 2018, the flatmap-stream/event-stream backdoor in 2018, and ua-parser-js in 2021. The pattern has not changed in eight years; only the delivery details have.

What pnpm actually does about it

pnpm's security model, documented on its supply chain security page, is consumption-side: it assumes the registry is the attack channel and makes it harder for a malicious version to execute on your machine.

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

# pnpm v10+: dependency build scripts are blocked by default.
# Allow only the ones you actually need.
allowBuilds:
  esbuild: true
  sharp: true

# pnpm v11+: do not resolve a version until it is 1 day old.
minimumReleaseAge: 1440   # minutes; 0 to opt out, 10080 for a week

# pnpm v11+: transitive deps may not come from git or tarball URLs.
blockExoticSubdeps: true

# pnpm v11+: refuse a version whose trust evidence dropped.
trustPolicy: no-downgrade
  • Blocked build scripts. Since pnpm v10, pnpm does not run dependency preinstall/install/postinstall scripts unless you approve them. The first pnpm install reports ignored scripts, and pnpm approve-builds walks you through an allowlist that lands in allowBuilds. dangerouslyAllowAllBuilds exists to turn the old behavior back on, and the docs are blunt that it is a bad idea. If a package never needed a build before, a compromised version will not suddenly run a malicious script, because that script would not be approved.
  • Minimum release age. Malware is usually detected and yanked within hours. pnpm v11 refuses to resolve a version until minimumReleaseAge has passed, 1440 minutes (one day) by default, which sits right in that detection window. Opt out with 0, or stretch it to 10080 for a week if you favor stability.
  • Exotic transitive dependencies. blockExoticSubdeps: true stops transitive dependencies from resolving through git repositories or direct tarball URLs, the two easiest ways to slip in code that was never vetted by a registry.
  • Trust policy. trustPolicy: no-downgrade refuses a version whose trust evidence decreased compared with previous releases, for example a package that used to be published by a trusted publisher and now only has provenance or nothing. trustPolicyExclude and trustPolicyIgnoreAfter carve out known-safe exceptions.
  • Lockfile integrity and registry pinning. pnpm-lock.yaml pins exact versions with sha512 integrity hashes of the tarballs, so the bytes you install are the bytes you reviewed. Since v11.20.0, packages from named registries are recorded with registry-qualified keys (<name>@<registryName>:<version>), so another registry cannot quietly substitute a same-name, same-version package.
  • Strict node_modules as blast-radius control. Because pnpm's tree only exposes what you declare, a compromised transitive dependency cannot be reached by your code or by tooling that walks node_modules looking for anything. Phantom imports were always a correctness bug; under this threat model they are a security bug.

The honest limits

I am not going to oversell this. pnpm cannot sandbox the scripts you do approve: an approved postinstall runs with your user, and dangerouslyAllowAllBuilds is a footgun. pnpm does not police git hooks, editor tasks, or CI workflow files, because those are your repository's own code. And strict node_modules does not help if the compromised package is a direct dependency you actually use. The tools that detect malicious packages early (Socket, Snyk, and similar) are worth running regardless, and pnpm's own docs point to them. The package manager is a layer, not a moat.

pnpm 12: the Rust rewrite

The pnpm 12 announcement (August 10, 2026) is deliberately short: pnpm 12 is a rewrite of pnpm in Rust, and it is a release candidate. Upgrading is not meant to be a migration: it keeps pnpm 11's commands, flags, settings, and lockfile format, and the documentation applies to both. The rewrite is visible in the benchmark as the Rust column, powered by an install engine the project calls pacquet. Three things differ, and exactly one of them fails outright rather than behaving differently.

Project-aware global bins

A globally installed node, deno, or bun now follows the version the current project pins, instead of always running the global one. If a project pins a runtime through devEngines.runtime, or by installing a runtime as a dependency, running node inside that project uses the pinned version; outside any project it uses the global one. The setting that controls this is globalShims, and the announcement's kicker is that you no longer need a separate version manager to get that behavior.

A pinned runtime in package.json looks like this:

{
  "name": "my-project",
  "devEngines": {
    "runtime": {
      "name": "node",
      "version": ">=24"
    },
    "packageManager": {
      "name": "pnpm",
      "version": ">=12"
    }
  }
}

For a team, this removes a whole class of works-on-my-machine bugs: the node in the shell is the node the project declared, and the same trick works for deno and bun if you install them per project. I would still pin the package manager itself via the packageManager field and corepack, because that is the only thing that makes pnpm install deterministic across machines. I track the pnpm release train the same way I track every dependency, using the release-tracking workflow I wrote up earlier.

Git dependency resolution: identity, not transport

For repositories on GitHub, GitLab, and Bitbucket, a specifier is now an identity, not a choice of transport. All of these name the same dependency and resolve identically: kevva/is-positive, github:kevva/is-positive, git+https://..., and git+ssh://.... Every one resolves through the host's canonical HTTPS URL, and pnpm never records an SSH URL for those hosts. The transport a machine uses is that machine's Git configuration, not the project's, so a lockfile written on a laptop with SSH keys installs on a CI runner that has none.

pnpm 11 could probe transports and record something like git@github.com:owner/repo.git, which then broke every machine without a key for that host. If an old lockfile has such an entry, re-resolve it once with pnpm update <package>; pnpm will not rewrite it on its own, because the lockfile is the record of what to install. For private repositories over SSH, configure the rewrite in Git rather than in the specifier:

$ git config --global url."git@github.com:".insteadOf https://github.com/

pnpm shells out to git, so the rewrite applies to all of its Git operations automatically.

pnpm install --resolution-only is gone

pnpm 12 does not implement this flag and rejects it: error: unexpected argument '--resolution-only' found. The flag existed to print peer dependency issues. pnpm peers check does that directly, reading the issues from the lockfile without a re-resolution or an install. This is the one change that stops a build instead of changing a result, so grep your CI scripts for --resolution-only before switching.

Trying it: pnpm 12 is published under the next-12 tag on npm and as a prerelease on GitHub. Homebrew, winget, Scoop, and Chocolatey do not offer it yet.

Should you switch?

Stay on npm if: you maintain a single small package with no workspaces, you have zero interest in dependency security posture, or your organization standardizes on npm and you do not want to be the exception. npm is fine. It is the default for a reason, and npm audit plus a lockfile covers the basics.

Switch to pnpm if: you run a monorepo or any multi-package repo (the store and workspace commands pay for themselves immediately); you care about install time (the benchmark gap is not subtle); or you take dependency supply chains seriously (blocked build scripts, minimum release age, and the trust policy are real, usable controls that npm does not have). The migration is low-risk: pnpm import converts an existing package-lock.json once, and the day-to-day commands are the same.

Try pnpm 12 now if: you want the Rust install engine in your CI. It is a release candidate and the announcement asks for bug reports, so use it where you can tolerate a prerelease, not on your only production machine on a Friday.

My own rule of thumb after a year of both: npm for throwaway scripts and single packages, pnpm for anything I expect to still be building in six months. The supply chain defaults alone are worth the switch, and the Rust rewrite makes the speed argument embarrassing for everyone else.

Official sources

  • pnpm 12 announcement: https://pnpm.io/blog/whats-different-in-pnpm-12
  • Official benchmarks: https://pnpm.io/benchmarks
  • Supply chain security guide: https://pnpm.io/supply-chain-security
  • Workspace settings: https://pnpm.io/settings
  • Snyk on the node-gyp compromise: https://security.snyk.io/node-gyp-supply-chain-compromise-june-2026
  • Splunk on Shai-Hulud: https://www.splunk.com/en_us/blog/security/npm-supply-chain-attack-detection-analysis.html

What are you running, npm, pnpm, or a mix? And are you planning to jump to pnpm 12 when it hits stable? Drop it in the comments.

Until next time, keep your systems thoughtful.

No comments yet