Skip to content

How Do You Migrate to pnpm Without Breaking the Build?

Moving a Yarn 4, Yarn 1, npm or Bun project to pnpm 12.8: the exact commands, six silent traps found by running them, and a rollback that held 10 of 10.

· · 9 min read
Large pile of brown cardboard boxes sealed with blue tape, ready for moving

Quick Take

Set `packageManager` to pnpm first, move overrides into `pnpm-workspace.yaml`, run `pnpm import`, delete the old files, run `pnpm install`. With the release-age gate off, the import changed no versions coming from npm or Yarn 1. Bun needs a bridge. Rollback by `git revert` worked 10 of 10.

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.

Several brown cardboard boxes of different sizes arranged on a wooden table
Photo by Michal Balog on Unsplash

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.

  1. pnpm import ignores overrides. A Yarn resolutions pin of picocolors 1.0.1 came out as 1.1.1. Move it into pnpm-workspace.yaml before importing, because after is ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
  2. Settings only live in pnpm-workspace.yaml. Hyphenated .npmrc settings, .yarnrc.yml and top-level overrides were ignored without a word. The "pnpm" field in package.json at least prints a warning.
  3. A workspace file without packages: installs nothing. Exit 0, "Already up to date", lockfile holding only pnpm itself.
  4. A leftover .pnp.cjs comes back to bite. pnpm injects it into NODE_OPTIONS if it exists, and Vitest fails again.
  5. Phantom dependencies finally surface. An undeclared picocolors import ends in Cannot find module. Run pnpm add picocolors. If you must, publicHoistPattern in the workspace file is the escape hatch, not CLI flags, which don't persist.
  6. Build scripts are blocked. esbuild's postinstall gave ERR_PNPM_IGNORED_BUILDS. Add allowBuilds: { esbuild: true } or run pnpm 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.

Share this Post on X Bluesky

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.

Stack of cardboard shipping boxes loaded inside the back of a delivery van
Photo by Claudio Schwarz on Unsplash

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, LinuxYarn 4.13pnpm 12.8.1
Cold install104.9s8.8s
Warm install81.0s2.8s
Nothing changed5.2s0.23s
Disk, modules plus cacheabout 3.0 GiB1.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.

Frequently Asked Questions

Can pnpm import a bun.lock file?
No. pnpm 12.8.1 reads package-lock.json, npm-shrinkwrap.json, Yarn 1 and Yarn 4 lockfiles, and fails on bun.lock with ERR_PNPM_LOCKFILE_NOT_FOUND. The bridge that worked in my tests is bun install --yarn --frozen-lockfile, which writes a yarn.lock and leaves bun.lock untouched. pnpm import then reads it and kept every locked version it carried over, 260 of 260 compared on the app and 304 of 304 on the monorepo. The fallback is deleting bun.lock and running a fresh pnpm install.
Why did my migrated lockfile fail on CI but pass on my machine?
Probably the release-age gate. pnpm 12.8.1 defaults to minimumReleaseAge of 1440 minutes. A lockfile that contains versions younger than that passes on the machine that created it and fails with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION on a fresh machine. In my test three entries triggered it, including a transitive one, so excluding only your direct pins did not help. It disappears once the versions are a day old.
Is it safe to roll back after migrating to pnpm?
Yes, if the old lockfile is still in git history. Reverting the migration commit and running the old manager's frozen install worked in 10 of 10 tests across five sources and two project shapes, with the old lockfile byte-identical. Without the old lockfile, 73 to 76 of 261 package names moved version on a 25-day-old lock, so keep it.
Is a green test suite enough to trust a pnpm migration?
No. On a 2,218-entry Yarn 4 monorepo, Vitest and tsc matched the Yarn baseline exactly (7,235 passing tests, identical error counts in every project), yet astro build of one blog failed because shared code imported an undeclared @resvg/resvg-js that Yarn's hoisting had hidden. Build every project after changing dependencies, because phantom dependencies surface at bundling time.