Moving to pnpm is four commands and one rule: change the lockfile's source of truth before you change anything else. The commands are easy. The rest of this article is the part that failed quietly: pins that vanished, a workspace that installed nothing, a lockfile that passed on my laptop and would have failed on CI.
What Do You Do Before the Switch?
Work on a branch, and keep the old lockfile in git history until the new one has survived a fresh CI run. That's the exit. Everything below assumes you can type git revert.
I tested on Linux with Node 22.23.3 and pnpm 12.8.1, serially, on a React 19 + Vite app and a 12-workspace monorepo, starting from npm 12.2, Yarn 1.22, Yarn 4.18 (node_modules and PnP) and Bun 1.4. Scripts and raw output are under audits/coding-dunia/2026-10-03-package-manager-migration/. The Docker daemon wasn't available and I couldn't run a real GitHub Actions job, so both appear below only as the commands I ran locally.
What Is the Exact Sequence?
# 1. In package.json: "packageManager": "pnpm@12.8.1"
# 2. If you had overrides or resolutions, put them in pnpm-workspace.yaml first
pnpm import
rm -rf node_modules package-lock.json # or yarn.lock, plus the files below
pnpm install
Step 1 isn't optional. A project still pinned to yarn@... makes both pnpm import and pnpm install stop with ERR_PNPM_OTHER_PM_EXPECTED. Delete list by source: npm and Yarn 1, the lockfile and node_modules. Yarn 4 adds .yarn/ and .yarnrc.yml. Yarn PnP adds .pnp.cjs and .pnp.loader.mjs, and those two are mandatory, for a reason below.
Bun is the odd one. pnpm import doesn't read bun.lock and fails with ERR_PNPM_LOCKFILE_NOT_FOUND. The bridge: bun install --yarn --frozen-lockfile writes a yarn.lock and leaves bun.lock byte-identical, then pnpm import works. Every version compared survived: 260 of 260 on the app, 304 of 304 on the monorepo.
For the monorepo, add three edits. Create pnpm-workspace.yaml with a packages: list, remove workspaces from the root package.json, and in npm or Yarn 1 projects rewrite "*" workspace dependencies to "workspace:*". With "*", pnpm asks the registry and returns a 404. The app migrated in 5.3 to 5.5 seconds (median of three per source, import plus first install).
Which Traps Are Silent?
Loud failures are fine. These six weren't.
pnpm importignores overrides. A Yarnresolutionspin ofpicocolors 1.0.1came out as 1.1.1. Move it intopnpm-workspace.yamlbefore importing, because after isERR_PNPM_LOCKFILE_CONFIG_MISMATCH.- Settings only live in
pnpm-workspace.yaml. Hyphenated.npmrcsettings,.yarnrc.ymland top-leveloverrideswere ignored without a word. The"pnpm"field inpackage.jsonat least prints a warning. - A workspace file without
packages:installs nothing. Exit 0, "Already up to date", lockfile holding only pnpm itself. - A leftover
.pnp.cjscomes back to bite. pnpm injects it intoNODE_OPTIONSif it exists, and Vitest fails again. - Phantom dependencies finally surface. An undeclared
picocolorsimport ends inCannot find module. Runpnpm add picocolors. If you must,publicHoistPatternin the workspace file is the escape hatch, not CLI flags, which don't persist. - Build scripts are blocked. esbuild's postinstall gave
ERR_PNPM_IGNORED_BUILDS. AddallowBuilds: { esbuild: true }or runpnpm approve-builds --all.
A migration you cannot undo with one git revert is not finished, so keep the old lockfile in history until the new one has survived a fresh CI run.
What About the Release-Age Gate?
pnpm 12.8.1 refuses versions younger than 24 hours by default. My fixtures pinned packages published the day before, so I hit it harder than you will, and the results fade as versions age. Still, the failure mode is worth knowing. With the gate on, pnpm import quietly swapped five transitive versions on the app (for example pino 10.4.0 to 10.3.1) and kept the direct ones. Worse, a lockfile created that way passed on my machine and then failed on a fresh checkout with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION, on three entries including a transitive one. Excluding only the direct pins didn't fix it. Setting minimumReleaseAge: 0 for the migration did. Ask whether you want the gate afterwards, because it exists to blunt supply-chain attacks.
Does the Move Change Your Versions?
Only if you skip the import. On a 25-day-old npm lockfile, a plain pnpm install moved 74 of 261 package names, while pnpm import moved none. Yarn 4 locks a node-gyp tree that pnpm drops, and all five sources passed typecheck, build and test afterwards.
What Does CI Need?
Commands I ran locally, succeeding: pnpm install --frozen-lockfile, then typecheck, build and test. The workflow file around them (actions/checkout, pnpm/action-setup, actions/setup-node) is where I'd look first, and my local run couldn't test it. On the real repo it did run, with pnpm/action-setup reading the version from packageManager, and it needed the two fixes described further down. I didn't test the cache: pnpm option, so check that one yourself. In Docker, corepack enable, pnpm fetch, then pnpm install --frozen-lockfile --offline worked in plain directories against a dead registry. An out-of-date lockfile fails with ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE, which is what you want.
This is the part I'd trust least from any local test. My other projects run Dockerfiles and GitHub Actions workflows too, and those are exactly the files a move like this breaks, because nothing on your laptop executes them. Rewrite them in the same pull request as the lockfile.
Corepack also guards the door. With pnpm pinned, the corepack npm and yarn shims refuse to run. The raw npm 12.2 binary doesn't check, and happily wrote a package-lock.json beside pnpm-lock.yaml. Add devEngines.packageManager with onFail: "error" if that matters.
Can You Go Back?
Yes. Reverting the migration commit and running the old manager's frozen install worked in 10 of 10 cases, with the old lockfile byte-identical. Three traps on the monorepo: restoring only package-lock.json leaves workspaces missing, so npm ci reports "up to date" and installs nothing. Leaving workspace:* behind gives EUNSUPPORTEDPROTOCOL in npm. And Yarn 1 chokes if packageManager still says pnpm. Revert the whole commit, never single files. Bun was the exception that needed nothing: it reads pnpm-lock.yaml itself with zero drift.
What Happened on a Real Monorepo?
Everything below comes from migrating the Coding Dunia blog, the site you're reading. It lives in a larger monorepo that holds several other projects too, which I won't name, plus shared packages, 2,218 locked entries and Yarn 4.13 with the node_modules linker. I moved the whole repo on a branch with the sequence above, and it has since been merged. The required validation check passes on my self-hosted runner. The browser test matrix there was still mixed when I merged, so I can't tell you yet how the first weeks go.
| Median of three, Linux | Yarn 4.13 | pnpm 12.8.1 |
|---|---|---|
| Cold install | 104.9s | 8.8s |
| Warm install | 81.0s | 2.8s |
| Nothing changed | 5.2s | 0.23s |
| Disk, modules plus cache | about 3.0 GiB | 1.65 GiB |
Yarn got a single run, pnpm three, and the registry answered fast, so read the cold gap as an upper bound. pnpm import added zero versions Yarn hadn't locked. It dedupes 154 packages to one copy and drops 23 entirely.
The surprise wasn't speed. Vitest went from 7,202 to 7,235 passing tests and tsc gave the identical error count in every project. Then astro build of one project died on a native module. Shared Open Graph code imports @resvg/resvg-js, most projects declare it, and that one didn't. Yarn had hoisted it into reach. Typecheck can't see that, and neither can a test. Only a build did. In all, 45 packages in 12 manifests needed declaring, and the worst, undici, kept 47 test files from loading.
Two more catches: corepack 0.34, bundled with Node 22.22, can't run pnpm 12 at all, and a bare pnpm deploy inside a blog runs the real deploy script. The first two CI runs on the self-hosted Mac found two more, and no local check could have. The workflow ran npx playwright install chromium from the repo root, and pnpm doesn't hoist undeclared binaries there, so it died with command not found; running it from the workspace that declares Playwright fixed it. Then pnpm install executed the root prepare script, which starts husky, and Yarn 4 never did. Husky repoints the git hooks path, and the next git lfs install refused with Hook already exists: pre-push. Setting HUSKY=0 on the install step fixed that. Two smaller notes: installConfig.hoistingLimits is silently ignored, and one overrides entry in the old config never matched anything, under Yarn either.
What the repo ended up with is smaller than the effort suggests. One new pnpm-workspace.yaml holds the workspace globs, the overrides moved over from Yarn's resolutions, an allowBuilds list (esbuild, sharp, @swc/core, workerd and @parcel/watcher on, core-js off) and a packageExtensions entry for the @types/react that satori never declared. Everything that called Yarn, from CI workflows to cron scripts, now calls pnpm, and one old guard that ignored pnpm-lock.yaml as a stray file had to be inverted.
The last catch came from merging main back into the branch, and it was the quietest of the lot. Dependabot kept bumping yarn.lock on main while the branch had deleted that file. Keeping the deletion carries over everything declared in a package.json, but two indirect security bumps lived only in the lockfile: devalue 5.9.2 to 5.9.4 and ip-address 10.7.0 to 10.7.3. The pnpm lockfile kept resolving the old versions, and pnpm install --frozen-lockfile passed anyway. A frozen install proves the lockfile agrees with the manifests, not that it kept what the old one had resolved. pnpm update devalue ip-address -r --lockfile-only fixed it in 12 changed lines. After any merge of the base branch, compare the resolved versions of everything the old lockfile bumped.
What Should You Do Next?
Run the sequence on a branch, build every project, and make a fresh CI checkout the judge. The case for choosing pnpm in the first place is in the pnpm benchmark. I'll add what the first weeks on the runner cost once there's something to measure.