Migrating from Yarn 1 to pnpm Workspaces
Yarn Classic hoists everything into one flat node_modules, so internal packages "just resolve" even when their dependencies are never declared. pnpm refuses that — it isolates each package and demands explicit workspace: references — which is exactly why a lift-and-shift migration breaks on the first pnpm install. This guide walks the conversion that turns a flat Yarn 1 workspace into a strict, content-addressable pnpm workspace without leaving phantom dependencies behind.
Exact symptoms and error messages
The migration typically fails during the first install with one of these:
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND In packages/web-app: "@scope/pkg-a@1.0.0" is in the
dependencies but no package named "@scope/pkg-a" is present in the workspace
ERR_PNPM_NO_MATCHING_VERSION No matching version found for @scope/pkg-a@workspace:*
Or, after a partial migration, a runtime failure because a previously hoisted dependency is no longer visible:
Error: Cannot find module 'lodash'
Require stack:
- /repo/packages/ui/src/index.js
That last one appears at runtime or build time, not install time, because Yarn 1 was silently satisfying an undeclared dependency through hoisting.
Root cause analysis
Yarn 1 resolves internal packages implicitly through the workspaces field and a flat node_modules, so transitive and undeclared dependencies are reachable by accident. pnpm enforces strict isolation through a content-addressable store and requires explicit workspace:* declarations for internal packages, reading discovery from pnpm-workspace.yaml rather than yarn.lock. Migrating without converting internal references or defining the workspace file leaves pnpm unable to find sibling packages, while the strict layout exposes every dependency Yarn's hoisting had been hiding. The architectural contrast — flat hoisting versus symlinked isolation — is exactly what the Workspace Configuration Deep Dive covers, and it is the key to a clean migration within your broader Core JavaScript Package Workflows.
Migrating from Yarn 1 to pnpm workspaces surfaces errors that are not migration bugs but pre-existing latent ones the stricter model exposes. Yarn 1 hoists dependencies to a flat node_modules, so a package can import something it never declared as long as a sibling pulled it in; pnpm's strict, symlinked layout gives each package only its declared dependencies, so those undeclared imports suddenly fail to resolve. The migration does not create these problems — it reveals them — and fixing them by declaring the missing dependencies leaves the workspace genuinely more correct.
The second source of migration friction is the change in lockfile and workspace declaration. Yarn 1 uses yarn.lock and the workspaces field; pnpm uses pnpm-lock.yaml and pnpm-workspace.yaml, and the internal dependency protocol becomes workspace:. These are mechanical translations, but a tool or script that hardcoded a path into Yarn's flat node_modules layout will break under pnpm's symlinked structure and needs updating to resolve through Node rather than by assuming a flat directory.
Resolution and configuration patch
Step 1 — Define pnpm workspace discovery. Create pnpm-workspace.yaml at the root with explicit globs so pnpm never scans node_modules or build output:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
Step 2 — Convert internal cross-package references from bare version strings to the workspace protocol:
{
"dependencies": {
"@scope/pkg-a": "workspace:*"
}
}
Step 3 — Remove legacy Yarn artifacts and install fresh:
rm yarn.lock
rm -rf node_modules
pnpm install
Never commit yarn.lock next to pnpm-lock.yaml; add the legacy lockfile to .gitignore or delete it outright so the resolver is never ambiguous.
Step 4 — Add the missing declarations pnpm now surfaces. Each Cannot find module 'x' means package x was hoisted by Yarn but never declared. Add it to the offending package's package.json and reinstall until the graph is clean. Pin the toolchain so the team migrates identically:
{
"packageManager": "pnpm@10.4.1"
}
Translate the workspace declaration, regenerate the lockfile, and fix the phantom dependencies the strict install surfaces:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
rm -rf node_modules yarn.lock
pnpm import # optionally seed pnpm-lock.yaml from yarn.lock
pnpm install # strict install surfaces undeclared dependencies
Each Cannot find module from the strict install names a package that was being used but not declared — add it to that package's dependencies and reinstall until the graph is clean. Convert internal dependency ranges to the workspace: protocol so they resolve locally and rewrite on publish.
CLI validation and debug commands
# Top-level workspace graph: every internal package should be linked
pnpm ls -r --depth=0
# Trace how a package resolves and from where
pnpm why react
# Find packages that import something they never declared (phantom deps)
pnpm install --frozen-lockfile # in CI this fails if the lockfile is stale
# Confirm no Yarn lockfile lingers
test -f yarn.lock && echo "DELETE yarn.lock" || echo "clean"
A clean pnpm ls -r --depth=0 with every @scope/* package showing a local link, plus a zero-exit --frozen-lockfile, confirms the migration resolved without phantom dependencies.
Validate the migrated workspace by proving the graph resolves cleanly and shared dependencies deduplicate, not just that the install ran:
# Frozen install must succeed against the new lockfile
pnpm install --frozen-lockfile
# Confirm a shared dependency resolves to a single copy
pnpm why react
# Run the full suite so undeclared imports on untested paths surface
pnpm -r test
# Ensure no workspace: specifier leaked into a publishable manifest
grep -r '"workspace:' packages/*/package.json && echo 'leaked' || echo 'clean'
The frozen install proves the lockfile matches the manifests, pnpm why confirms deduplication, the recursive test run exercises code paths a build alone would not, and the grep confirms internal specifiers will be rewritten on publish rather than shipping raw.
Prevention and CI/CD guardrails
- Run
pnpm install --frozen-lockfileas the first CI step so a stale or hand-edited lockfile fails before any build. - Delete
yarn.lockand add it to.gitignoreso the two resolvers can never disagree. - Pin pnpm via
packageManager+ Corepack so every machine produces the samepnpm-lock.yaml. - Add
auto-install-peers=trueandstrict-peer-dependencies=trueto.npmrcto surface peer gaps that Yarn 1 ignored. - After migration, audit with
pnpm ls -r --depth=0for any package missing a declaration it relies on.
- Expect and fix phantom dependencies — each is a real undeclared import the strict install revealed.
- Convert internal dependencies to the
workspace:protocol rather than hardcoded versions. - Update any tool that assumed Yarn's flat
node_modulesto resolve through Node instead. - Pin pnpm with
packageManagerand enable Corepack so the whole team migrates to the same version.
Verifying the migration is complete and correct
A migration is done not when the install succeeds but when the workspace is provably as correct as before. The install succeeding only means the declared graph resolves; it does not prove that every phantom dependency was found, because a package might import an undeclared dependency only on a code path the build does not exercise. Running the full test suite across all packages after the migration exercises those paths, so a lingering undeclared import surfaces as a test failure rather than a production one.
The verification checklist is concrete: a frozen install succeeds, the full test suite passes across the workspace, pnpm why on key shared dependencies shows a single resolved copy where you expect it, and no workspace: specifier has leaked into any package that will be published. Confirming each turns the migration from hopeful to verified. The payoff for the strictness is durable: a workspace whose dependency graph the tooling now enforces, faster and more disk-efficient installs from the content-addressed store, and the phantom-dependency class of bug eliminated by construction rather than left to chance.
Rolling back safely and migrating incrementally
A migration of any size benefits from a safe rollback path, and with package managers that path is straightforward because the change is contained in a few files. Keeping the migration on a branch, with the old yarn.lock and node_modules recoverable, means that if the strict install surfaces more phantom dependencies than the team can fix in one sitting, you can pause without having broken the main branch. Because pnpm's changes are the workspace declaration, the lockfile, and the dependency fixes, reverting is a matter of restoring those files rather than unwinding a sprawling change.
For a large workspace, migrating incrementally reduces risk further. Rather than flipping the whole repo at once, some teams first adopt pnpm's strictness in a subset of packages, fix the phantom dependencies there, and expand — or run the strict install in CI as a non-blocking check that reports undeclared dependencies before making it the default. The goal either way is to treat the phantom-dependency cleanup as the real work of the migration and to sequence it so the team can absorb it, ending with a workspace whose dependency graph the tooling now enforces, faster installs from the content-addressed store, and the undeclared-import class of bug eliminated by construction.
Handling Yarn-specific features during migration
Yarn 1 has features that do not map one-to-one onto pnpm, and knowing the equivalents avoids surprises mid-migration. Yarn's resolutions field, used to force transitive versions, becomes pnpm's pnpm.overrides with the same intent but a slightly different syntax. Yarn's nohoist — used to keep certain dependencies un-hoisted — is largely unnecessary under pnpm, whose strict symlinked layout does not hoist by default, so most nohoist entries can simply be dropped. Any reliance on Yarn's flat node_modules layout by a build script or tool needs updating to resolve through Node rather than by assuming a directory structure.
Workspace-range syntax is the other translation point. Yarn 1 references internal packages by version or with its own workspace resolution; pnpm uses the explicit workspace: protocol, which is more capable — distinguishing workspace:*, workspace:^, and workspace:~ for how the range rewrites on publish. Converting internal dependencies to workspace:^ is usually the right default, giving external consumers a caret range while resolving to the local package in development. Mapping each Yarn-specific feature to its pnpm equivalent, and dropping the ones the stricter model makes unnecessary, is what turns the migration from a source of mysterious breakage into a mechanical, well-understood translation.
Frequently Asked Questions
Can pnpm automatically convert a Yarn 1 lockfile to pnpm-lock.yaml?
No. pnpm does not import yarn.lock. Delete it, convert internal dependencies to the workspace:* protocol, and run pnpm install to generate a fresh pnpm-lock.yaml.
Why does pnpm throw ERR_PNPM_WORKSPACE_PKG_NOT_FOUND after migration?
An internal dependency still uses a bare version string or file: path instead of the workspace:* protocol pnpm's strict resolver requires, so pnpm looks for it in the registry and fails. Convert the reference to workspace:*.
How do I handle shared devDependencies across pnpm workspaces?
Declare shared tooling such as TypeScript and ESLint in the root package.json devDependencies; pnpm makes them available workspace-wide. For strict isolation, declare them explicitly in each package that uses them instead.
Is the workspaces field in package.json still required for pnpm?
No — pnpm relies solely on pnpm-workspace.yaml. Keeping the workspaces field is harmless and preserves compatibility with npm and Yarn Berry tooling.
Why do I get 'Cannot find module' errors after migrating to pnpm?
Because pnpm's strict, symlinked layout only lets a package import what it declares, exposing phantom dependencies that Yarn 1's hoisting hid. Each error names a package that was used but never declared — add it to that package's dependencies and reinstall. These are latent bugs the migration reveals, not new ones.
How do I convert Yarn workspace dependencies to pnpm?
Translate workspaces to pnpm-workspace.yaml, regenerate the lockfile (pnpm import can seed it from yarn.lock), and change internal dependency ranges to the workspace: protocol so they resolve locally and rewrite to real versions on publish.
How do I verify the migration didn't miss a phantom dependency?
Run the full test suite across all packages (pnpm -r test), because a build alone may not exercise every code path that imports an undeclared dependency. A lingering phantom import then surfaces as a test failure rather than a production one.
What happens to Yarn resolutions and nohoist when migrating to pnpm?
resolutions becomes pnpm.overrides with the same intent. Most nohoist entries can be dropped, because pnpm's strict symlinked layout does not hoist by default, so the situations nohoist worked around usually do not arise.
Can I run Yarn and pnpm side by side during migration?
Only on separate branches — a single checkout must commit to one manager, since the lockfile format differs. Keep the migration on a branch with the old yarn.lock recoverable so you can fall back until the strict install is clean.
Related
- Workspace Configuration Deep Dive — the workspace-protocol and isolation model behind this migration.
- Setting Up npm Workspaces for Small Teams — the alternative if you want native npm rather than pnpm.
- Lockfile Management Strategies — committing and enforcing the new pnpm-lock.yaml.