Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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 a flat Yarn 1 workspace to isolated pnpm A flat hoisted node_modules with bare version strings converts to an isolated pnpm store with workspace-protocol references. Yarn 1 (flat) one hoisted node_modules internal dep: "@scope/a": "1.0.0" undeclared deps resolve by accident discovery: yarn.lock pnpm (isolated) content-addressable store internal dep: "@scope/a": "workspace:*" only declared deps are visible discovery: pnpm-workspace.yaml convert refs + declare missing deps
The migration converts bare internal version strings to the workspace protocol and forces every package to declare its real dependencies.

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"
}

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.

Prevention and CI/CD guardrails

  • Run pnpm install --frozen-lockfile as the first CI step so a stale or hand-edited lockfile fails before any build.
  • Delete yarn.lock and add it to .gitignore so the two resolvers can never disagree.
  • Pin pnpm via packageManager + Corepack so every machine produces the same pnpm-lock.yaml.
  • Add auto-install-peers=true and strict-peer-dependencies=true to .npmrc to surface peer gaps that Yarn 1 ignored.
  • After migration, audit with pnpm ls -r --depth=0 for any package missing a declaration it relies on.

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.

Related

Workspace Configuration Deep Dive