| | |

pnpm 05 📦 Managing Dependencies: Add, Update, and Remove Operations

⏱️ Estimated read: 9 min | |

The daily rhythm of package management is a three-command cycle: add what you need, update what has changed, and remove what is no longer used. pnpm’s commands for these operations follow a consistent pattern: they modify package.json, resolve the change into pnpm-lock.yaml, and update the virtual store and symlinks in node_modules. The critical difference from npm and Yarn lies in the flags that control where a dependency is placed and how the lockfile is written, particularly in the pnpm 10 era of strict dependency trees and default security blocking.

Key point: pnpm add installs and saves to dependencies by default. pnpm update respects the ranges in package.json unless --latest is passed. pnpm remove deletes from every dependency field unless a --save-* flag targets a specific field. All three commands update the lockfile and the symlink graph in tandem.


Why dependency operations differ in pnpm

The strict tree consequence. In npm or Yarn Classic, adding a dependency often causes hoisting to rearrange node_modules unpredictably. In pnpm, adding a package creates a new symlink at the root and a new hard-linked folder inside .pnpm. The rest of the tree remains untouched. This isolation means that adding a package cannot break a transitive dependency that was already working, because those dependencies were never hoisted to the root in the first place.

The lifecycle script block. pnpm 10 changed the default behavior of preinstall and postinstall scripts. Packages that rely on these scripts to build native code (like esbuild or sharp) will not run them automatically. The --allow-build flag must be used to explicitly permit a package to execute its install scripts, which also records the permission in pnpm-workspace.yaml for future installs . This is a supply-chain security measure that changes the workflow for native dependencies.

The workspace root check. Adding a dependency to the root of a workspace requires the -w flag (--ignore-workspace-root-check). Without it, pnpm refuses to modify the root package.json . This prevents accidental installation of tools into the root that should belong to a specific workspace package.

The update semantics. pnpm update without arguments respects the semver ranges in package.json. It will update within the range but will not cross a major version boundary. The --latest flag ignores the ranges and installs the latest version available, which may be a major version bump. This distinction is critical for CI environments where unexpected major updates can break builds .

The trade-off. pnpm’s strictness means that a dependency you add is visible only to the package that declared it. If you add lodash to a workspace package, other workspaces cannot import it. This forces explicit declaration but adds friction in monorepos where shared utilities are common.


a. Adding dependencies

pnpm add installs a package and saves it to dependencies by default. The destination field is controlled by flags: -D for devDependencies, -O for optionalDependencies, and -P for explicit production dependencies .

# Add to dependencies
pnpm add express

# Add to devDependencies
pnpm add -D typescript

# Add an exact version (no caret)
pnpm add -E lodash@4.17.21

# Add a peer dependency (also installs as dev)
pnpm add --save-peer react

The --save-exact (-E) flag writes the exact version to package.json, bypassing the default caret range. This is useful for tools where minor updates are known to introduce breaking changes .

For packages from non-npm sources, pnpm supports protocols. A JSR package uses jsr:, a workspace package uses workspace:, and a local tarball uses a file path. Aliases allow installing a package under a different name:

# Install @babel/core under the name babel-core
pnpm add babel-core@npm:@babel/core@8.0.6

This writes "babel-core": "npm:@babel/core@8.0.6" to package.json. The npm: prefix is the alias protocol, not a registry selector .

The --allow-build flag is required for packages with install scripts. Without it, the scripts are silently skipped, and native modules may fail at runtime.

pnpm --allow-build=esbuild add my-bundler

This runs esbuild‘s postinstall script and records the permission in onlyBuiltDependencies within pnpm-workspace.yaml, so future installs also allow it .


b. Updating dependencies

pnpm update (aliases: up, upgrade) updates packages to their latest versions within the ranges declared in package.json .

# Update all dependencies within declared ranges
pnpm update

# Update a specific package
pnpm update express

# Update beyond declared ranges (may cross major versions)
pnpm update --latest

# Interactive update
pnpm update --interactive

Patterns allow scoped updates. pnpm update "@babel/*" updates all Babel packages. The negation pattern pnpm update "\!webpack" updates everything except webpack .

The --recursive (-r) flag runs the update in every workspace package concurrently. The --workspace flag restricts updates to workspace packages only, failing if a specified package is not found in the workspace .

A critical caveat for pnpm 11+: pnpm update <name>@<version> fails if the package is not a direct dependency of any selected project. Transitive dependencies must be updated via overrides instead .


c. Removing dependencies

pnpm remove (aliases: rm, uninstall, un) removes a package from node_modules and from package.json .

# Remove from all dependency fields
pnpm remove lodash

# Remove only from devDependencies
pnpm remove -D typescript

Without a --save-* flag, the package is removed from every field it appears in. The --save-dev, --save-optional, and --save-prod flags target specific fields, failing silently if the package is not in that field .

The --recursive (-r) flag removes the package from every workspace package. This is useful for cleaning up a dependency that was added to the wrong scope or is no longer needed anywhere .

The --lockfile-only flag updates package.json and pnpm-lock.yaml without touching node_modules. This is useful for scripted changes where the actual installation is deferred .


Complete Example Session

# ============================================
# PART 1: ADD A PRODUCTION DEPENDENCY
# ============================================

pnpm add express
# package.json: "express": "^5.1.0"
# node_modules/express -> .pnpm/express@.../node_modules/express
# ============================================
# PART 2: ADD A DEV DEPENDENCY
# ============================================

pnpm add -D typescript
# package.json: "typescript": "^5.8.0" (devDependencies)
# ============================================
# PART 3: ADD WITH EXACT VERSION
# ============================================

pnpm add -E lodash@4.17.21
# package.json: "lodash": "4.17.21" (no caret)
# ============================================
# PART 4: ADD A NATIVE PACKAGE WITH SCRIPT PERMISSION
# ============================================

pnpm --allow-build=esbuild add esbuild
# esbuild postinstall runs.
# Permission recorded in pnpm-workspace.yaml.
# ============================================
# PART 5: UPDATE WITHIN RANGES
# ============================================

pnpm update express
# Updates to latest 5.x.x within ^5.1.0 range.
# Lockfile updated.
# ============================================
# PART 6: UPDATE BEYOND RANGES
# ============================================

pnpm update --latest
# All dependencies updated to latest.
# Major version bumps may occur.
# ============================================
# PART 7: REMOVE A DEPENDENCY
# ============================================

pnpm remove lodash
# package.json: lodash removed from dependencies.
# node_modules: lodash symlink removed.
# ============================================
# PART 8: REMOVE FROM ALL WORKSPACES
# ============================================

pnpm remove -r lodash
# lodash removed from every workspace package.
# ============================================
# PART 9: VERIFY THE GRAPH
# ============================================

ls -la node_modules/express
# symlink to .pnpm/express@5.1.0/node_modules/express

The nine parts trace the lifecycle: adding production and dev dependencies, pinning exact versions, permitting native build scripts, updating within and beyond ranges, removing from a single package, removing recursively from a workspace, and verifying the resulting symlink graph.


Quick Reference

Add Commands

CommandDestination
pnpm add <pkg>dependencies
pnpm add -D <pkg>devDependencies
pnpm add -O <pkg>optionalDependencies
pnpm add -E <pkg>Exact version
pnpm add --save-peer <pkg>peerDependencies + dev
pnpm add --allow-build=<pkg> <pkg>Allow install scripts

Update Commands

CommandEffect
pnpm updateUpdate within ranges
pnpm update --latestUpdate to latest, ignoring ranges
pnpm update --interactiveChoose updates interactively
pnpm update -rUpdate all workspaces
pnpm update "@scope/*"Update by pattern

Remove Commands

CommandEffect
pnpm remove <pkg>Remove from all fields
pnpm remove -D <pkg>Remove from devDependencies
pnpm remove -r <pkg>Remove from all workspaces
pnpm remove --lockfile-only <pkg>Update manifests without node_modules

Best Practices

✅ Do This:

pnpm add -D typescript
<!-- Build tools belong in devDependencies -->        # ✅
pnpm update --interactive
<!-- Review each update before applying -->          # ✅
pnpm --allow-build=esbuild add esbuild
<!-- Explicitly permit native build scripts -->      # ✅

❌ Don’t Do This:

# Add a dependency without -D to a package that only runs in tests
<!-- Pollutes production dependencies -->            # ❌
# Run pnpm update --latest without reviewing
<!-- Major version bumps can break APIs -->          # ❌
# Assume install scripts run automatically
<!-- pnpm 10 blocks them by default -->              # ❌

Common Pitfalls

PitfallWhy It HappensFix
Native module failsInstall script blocked by default--allow-build=<pkg>
Cannot add to workspace rootMissing -w flagpnpm add <pkg> -w
Update does not cross majorRanges constrain updatepnpm update --latest
Remove fails on workspace packageNot declared in that workspacepnpm remove -r <pkg>
Lockfile out of syncManual package.json editRun pnpm install to reconcile

Real-World Examples

1. Add Production Dependency

pnpm add express

2. Add Dev Dependency

pnpm add -D vitest

3. Add Exact Version

pnpm add -E lodash@4.17.21

4. Add Peer Dependency

pnpm add --save-peer react

5. Add with Build Permission

pnpm --allow-build=esbuild add esbuild

6. Update Within Range

pnpm update express

7. Update to Latest

pnpm update --latest

8. Interactive Update

pnpm update -i

9. Remove from All Fields

pnpm remove lodash

10. Remove from All Workspaces

pnpm remove -r lodash

Visual

Add Operation Flow

┌──────────────────────────────────────────────┐
│  pnpm add express                            │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  1. Resolve version (^5.1.0)                 │
│  2. Check global store for express@5.1.0     │
│  3. Hard-link into .pnpm/express@5.1.0/      │
│  4. Create symlink at node_modules/express   │
│  5. Write to package.json and lockfile       │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Dependency available                        │
└──────────────────────────────────────────────┘

Update Decision

┌──────────────────────────────────────────────┐
│  pnpm update                                 │
│  (without --latest)                          │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  Check package.json range: ^5.1.0            │
│  Find latest version satisfying ^5.1.0       │
│  → Update lockfile if newer exists           │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  pnpm update --latest                        │
│  → Ignore range, use latest tag              │
│  → May cross major version boundary          │
└──────────────────────────────────────────────┘

Summary

ItemValue
Add defaultdependencies
Add dev flag-D
Add exact flag-E
Install script permission--allow-build=<pkg>
Update defaultWithin ranges
Update latest flag--latest
Remove defaultAll fields
Remove workspace flag-r
Root workspace add-w

Key takeaways:

  • pnpm add defaults to dependencies. Use -D for build tools and test runners, -E for exact versions, and --save-peer for plugins that expect a shared runtime .
  • Install scripts are blocked by default in pnpm 10. Native packages require --allow-build=<pkg> to run their postinstall scripts. The permission is recorded for future installs .
  • pnpm update respects ranges; --latest ignores them. The default behavior prevents accidental major version bumps. Use --latest only when you intend to cross major versions .
  • pnpm remove cleans every field by default. The --save-* flags target specific fields. Recursive removal (-r) applies to all workspace packages .
  • Workspace root additions require -w. This prevents accidental installation of tools that should belong to a specific workspace package .

Remember: The three commands are the daily cycle. add grows the tree, update refreshes it, and remove prunes it. Each command modifies package.json, pnpm-lock.yaml, and the symlink graph in .pnpm together. The flags control where the dependency lands and whether native scripts are permitted to run. pnpm’s strictness means that every addition is isolated, every removal is clean, and every update is constrained by the ranges you declared.

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!