| | |

pnpm 15 📦 Migrating Existing Projects from npm or Yarn to pnpm

⏱️ Estimated read: 10 min | |

Migrating a project from npm or Yarn to pnpm is not a single command. It is a sequence of decisions: how to convert the lockfile, how to handle the strict dependency resolution that pnpm enforces, how to rewrite CI scripts, and how to deal with the inevitable phantom dependency that npm’s hoisting was hiding. The LFCS exam does not test pnpm, but the pnpm ecosystem documentation is explicit about the migration path. The most important distinction is between the lockfile conversion (pnpm import) and the installation (pnpm install). The import creates a pnpm-lock.yaml from the existing package-lock.json or yarn.lock. The install then resolves the dependency tree against that lockfile and fails if a dependency is missing from the manifest .

Key point: pnpm import generates a pnpm-lock.yaml from an existing npm or Yarn lockfile. It does not modify package.json or node_modules. The migration is only complete when pnpm install succeeds and the old lockfile is deleted. In a workspace, pnpm-workspace.yaml must exist before pnpm import runs, or workspace packages are silently skipped .


Why migration requires a deliberate sequence

The lockfile conversion problem. npm and pnpm use different lockfile formats. package-lock.json records the physical node_modules layout, including hoisting decisions. pnpm-lock.yaml records only the logical dependency tree. pnpm import reads the npm lockfile and generates a pnpm lockfile that prefers the versions from the source. The conversion is not one-to-one: if the npm lockfile does not satisfy the package.json manifests, pnpm resolves new versions during the import . The result is a valid lockfile, but the dependency tree may differ slightly from what npm installed.

The phantom dependency problem. npm’s hoisting allows a package to import a dependency it never declared. pnpm’s strict layout blocks this. A project that worked under npm may fail under pnpm with Cannot find module 'lodash' because lodash was hoisted from a transitive dependency and never added to package.json . The fix is to add the missing dependency explicitly with pnpm add lodash. This is not a bug in pnpm; it is a declaration that was missing from the manifest.

The symlink compatibility problem. Some tools do not follow symlinks correctly. A bundler, a test runner, or an IDE plugin that assumes a flat node_modules may fail under pnpm’s default isolated layout. The escape hatch is nodeLinker: hoisted in pnpm-workspace.yaml, which produces an npm-like flat layout. The setting sacrifices pnpm’s disk efficiency and strictness, but it makes legacy tools work .

The configuration migration problem. pnpm 11 stopped reading non-auth settings from .npmrc. Registry URLs and authentication tokens remain in .npmrc. Everything else—nodeLinker, hoist, strictPeerDependencies, autoInstallPeers—moved to pnpm-workspace.yaml with camelCase keys . A project migrating from npm or Yarn that has .npmrc settings for hoisting or peer dependencies must translate those settings to the YAML file.

The trade-off. Migration is not a one-command operation. It requires running the import, attempting the install, fixing the phantom dependencies that the install reveals, and then adjusting the configuration if a tool breaks. The benefit is a strict dependency tree that mirrors production, a lockfile that is reviewable, and disk savings from the content-addressable store.


a. Converting the lockfile and installing

The migration begins with pnpm import. The command reads the existing lockfile (package-lock.json, yarn.lock, or npm-shrinkwrap.json) and generates pnpm-lock.yaml. It does not delete the source lockfile or modify node_modules .

# Install pnpm if not present
npm install -g pnpm

# Remove the existing node_modules
rm -rf node_modules

# Convert the lockfile
pnpm import

# Install dependencies
pnpm install

The pnpm import command fails if no supported lockfile is found. It also fails if the source lockfile cannot be parsed, such as a yarn.lock with merge conflict markers. In a workspace, the pnpm-workspace.yaml file must exist before the import runs. Without it, pnpm does not know which directories are workspaces, and the import skips them .

After the install succeeds, the old lockfile must be deleted. Keeping both package-lock.json and pnpm-lock.yaml creates ambiguity: npm and pnpm resolve dependencies differently, and the lockfiles will diverge over time.

# Remove the npm lockfile
rm package-lock.json

# Commit the pnpm lockfile
git add pnpm-lock.yaml
git commit -m "Migrate from npm to pnpm"

b. Fixing phantom dependencies and tool compatibility

The first pnpm install after migration often fails with a missing module error. The module exists in the dependency tree—it was installed as a transitive dependency of something else—but pnpm’s strict root layout does not expose it to the project’s own code .

Error: Cannot find module 'lodash'

The fix is to declare the dependency explicitly.

# Add the missing dependency to package.json
pnpm add lodash

If the missing module is a peer dependency, pnpm reports it during install. The autoInstallPeers setting (default true in pnpm 8+) installs peer dependencies automatically. If a specific peer dependency is known to be incompatible, the peerDependencyRules.ignoreMissing setting suppresses the warning .

For tools that break on symlinks, the nodeLinker setting in pnpm-workspace.yaml changes the layout.

# pnpm-workspace.yaml
nodeLinker: hoisted

The hoisted layout produces an npm-like node_modules. The publicHoistPattern setting can hoist specific packages without abandoning the isolated layout entirely.

publicHoistPattern:
  - '*eslint*'
  - '*babel*'

c. Updating CI and scripts

A CI pipeline that runs npm ci must be rewritten to use pnpm. The pnpm/action-setup@v4 action installs pnpm and reads the version from packageManager in package.json. The pnpm install --frozen-lockfile command replaces npm ci .

# Before (npm)
- run: npm ci

# After (pnpm)
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile

The packageManager field in package.json pins the pnpm version. Corepack reads this field and ensures every developer and CI runner uses the same version.

{
  "packageManager": "pnpm@10.0.0"
}

Scripts in package.json that reference npm run must be updated to pnpm run. The npx command is replaced by pnpm exec or pnpm dlx. In a workspace, npm run build --workspaces becomes pnpm -r run build. A specific workspace script is selected with pnpm --filter <name> run <script> .

For monorepos migrating from npm workspaces, the pnpm-workspace.yaml file replaces the workspaces field in package.json. The internal dependencies use the workspace:* protocol.

# pnpm-workspace.yaml
packages:
  - 'packages/*'
{
  "dependencies": {
    "@myorg/utils": "workspace:*"
  }
}

Complete Example Session

# ============================================
# PART 1: INSTALL PNPM
# ============================================

npm install -g pnpm
# pnpm is now available as a command.
# ============================================
# PART 2: REMOVE NODE_MODULES
# ============================================

rm -rf node_modules
# The existing install is discarded.
# ============================================
# PART 3: CONVERT THE LOCKFILE
# ============================================

pnpm import
# pnpm-lock.yaml generated from package-lock.json.
# ============================================
# PART 4: INSTALL
# ============================================

pnpm install
# The install may fail with missing module errors.
# ============================================
# PART 5: FIX PHANTOM DEPENDENCIES
# ============================================

pnpm add lodash
# The missing dependency is added explicitly.
# ============================================
# PART 6: REINSTALL
# ============================================

pnpm install
# The install succeeds.
# ============================================
# PART 7: DELETE THE OLD LOCKFILE
# ============================================

rm package-lock.json
git add pnpm-lock.yaml
git commit -m "Migrate to pnpm"
# ============================================
# PART 8: UPDATE CI
# ============================================

# .github/workflows/ci.yml
# - uses: pnpm/action-setup@v4
# - run: pnpm install --frozen-lockfile
# ============================================
# PART 9: PIN THE PACKAGE MANAGER
# ============================================

# package.json
# "packageManager": "pnpm@10.0.0"

The nine parts trace the migration: installation, cleanup, lockfile conversion, installation attempt, phantom dependency fix, reinstallation, lockfile deletion, CI update, and version pinning.


Quick Reference

Migration Commands

CommandPurpose
pnpm importConvert npm/Yarn lockfile to pnpm-lock.yaml
pnpm installInstall from the new lockfile
rm package-lock.jsonDelete the old lockfile after successful install
pnpm add <pkg>Fix phantom dependencies

Configuration Split (pnpm 11)

SettingLocation
Registry URLs.npmrc
Authentication tokens.npmrc or global config
nodeLinkerpnpm-workspace.yaml
autoInstallPeerspnpm-workspace.yaml
strictPeerDependenciespnpm-workspace.yaml

CI Migration

Before (npm)After (pnpm)
npm cipnpm install --frozen-lockfile
npm run buildpnpm run build
npx <cmd>pnpm exec <cmd>
npm install <pkg>pnpm add <pkg>

Script Migration (Workspaces)

Before (npm)After (pnpm)
npm run build --workspacespnpm -r run build
npm run build -w <pkg>pnpm --filter <pkg> run build

Best Practices

✅ Do This:

pnpm import
<!-- Convert the lockfile before installing -->         # ✅
pnpm add <missing-package>
<!-- Declare phantom dependencies explicitly -->       # ✅
nodeLinker: hoisted
<!-- Use only when a tool breaks on symlinks -->       # ✅

❌ Don’t Do This:

# Delete package-lock.json before pnpm import
<!-- The import requires the source lockfile -->       # ❌
# Keep both package-lock.json and pnpm-lock.yaml
<!-- Ambiguity about which is authoritative -->         # ❌
# Assume all tools work with symlinks
<!-- Test the build; fall back to hoisted if needed --> # ❌

Common Pitfalls

PitfallWhy It HappensFix
pnpm import failsNo supported lockfileVerify package-lock.json or yarn.lock exists
Missing module after installPhantom dependencypnpm add <package>
Workspace packages skippedNo pnpm-workspace.yaml before importCreate the file, then import
Tool cannot find packageSymlink incompatibilitynodeLinker: hoisted
CI fails with frozen lockfileLockfile not committedgit add pnpm-lock.yaml

Real-World Examples

1. Import Lockfile

pnpm import

2. Install After Import

pnpm install

3. Fix Phantom Dependency

pnpm add lodash

4. Fallback to Hoisted Layout

nodeLinker: hoisted

5. Update CI

- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile

6. Pin pnpm Version

{ "packageManager": "pnpm@10.0.0" }

7. Delete Old Lockfile

rm package-lock.json

8. Workspace Migration

packages:
  - 'packages/*'

9. Update Scripts

{ "scripts": { "build": "pnpm run build:wasm && pnpm run build:vite" } }

10. Filtered Execution

pnpm --filter @myorg/app run build

Visual

Migration Flow

┌──────────────────────────────────────────────┐
│  npm or Yarn project                         │
│  ├── package.json                            │
│  ├── package-lock.json (or yarn.lock)        │
│  └── node_modules/                           │
└──────────────────┬───────────────────────────┘
                   │
                   │ rm -rf node_modules
                   ▼
┌──────────────────────────────────────────────┐
│  pnpm import                                 │
│  → pnpm-lock.yaml generated                  │
└──────────────────┬───────────────────────────┘
                   │
                   │ pnpm install
                   ▼
┌──────────────────────────────────────────────┐
│  Install succeeds?                           │
│  ├─ YES → Delete old lockfile                │
│  └─ NO  → Add missing dependencies           │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Commit pnpm-lock.yaml                       │
│  Update CI to pnpm                           │
└──────────────────────────────────────────────┘

Phantom Dependency Discovery

┌──────────────────────────────────────────────┐
│  Code: import lodash from 'lodash'           │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  npm: lodash hoisted → works                 │
│  pnpm: lodash not declared → fails           │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Fix: pnpm add lodash                        │
│  → lodash added to package.json              │
│  → import succeeds                           │
└──────────────────────────────────────────────┘

Summary

ItemValue
Lockfile conversionpnpm import
Supported sourcespackage-lock.json, yarn.lock, npm-shrinkwrap.json
Install after importpnpm install
Phantom dependency fixpnpm add <package>
Symlink fallbacknodeLinker: hoisted
CI replacementpnpm install --frozen-lockfile
Version pinpackageManager in package.json
Old lockfileDelete after successful install

Key takeaways:

  • pnpm import converts the lockfile, not the manifest. The command reads package-lock.json or yarn.lock and generates pnpm-lock.yaml. It does not modify package.json or node_modules .
  • Phantom dependencies surface immediately. A package that was hoisted under npm and imported without declaration will fail under pnpm. The fix is pnpm add <package>, which adds the missing declaration .
  • pnpm 11 split configuration. Registry and auth stay in .npmrc. Everything else—nodeLinker, autoInstallPeers, strictPeerDependencies—moves to pnpm-workspace.yaml with camelCase keys .
  • Symlink compatibility requires a fallback. Some tools do not follow symlinks. The nodeLinker: hoisted setting produces an npm-like layout for those tools.
  • CI must use --frozen-lockfile. The pnpm/action-setup@v4 action installs pnpm and reads the version from packageManager. The pnpm install --frozen-lockfile command replaces npm ci .

Remember: Migration is a process, not a command. The import creates the lockfile, the install reveals the missing declarations, and the configuration adjustments handle the tools that cannot follow symlinks. The old lockfile is deleted only after the install succeeds. The CI pipeline is updated to enforce the new lockfile. The project is on pnpm when pnpm install --frozen-lockfile passes in CI.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!