pnpm Workspace Configuration: pnpm-workspace.yaml, .npmrc, and Package Manifest Patterns

Last updated:

A pnpm monorepo is configured across three files: pnpm-workspace.yaml (which packages belong to the workspace, plus catalogs and pnpm-specific overrides), the root package.json (the package manager version, shared devDependencies, and top-level scripts), and .npmrc (install behavior like auto-install-peers, dedupe-peer-dependents, and link-workspace-packages). The YAML file holds the workspace shape; the JSON manifests describe individual packages and their dependencies, including the workspace: protocol that links sibling packages by symlink instead of from the npm registry. This guide is a reference for every config surface in a 2026-era pnpm v9 workspace, including catalogs and the filter syntax updates. If you are coming from npm workspaces or Yarn workspaces, the comparison section at the end maps each concept across managers.

Hand-editing a package.json in a monorepo and the install keeps failing? Paste the manifest into Jsonic's JSON Validator first — it surfaces trailing commas, unbalanced braces, and the kind of typos that produce confusing pnpm errors at install time.

Validate package.json

pnpm-workspace.yaml: declaring workspace packages

pnpm-workspace.yaml lives at the repo root and tells pnpm which folders count as workspace packages. The minimum file is a packages: array of glob patterns. Anything matched by those globs that contains a package.json becomes part of the workspace and can be referenced via the workspace: protocol from any other package.

# pnpm-workspace.yaml
packages:
  - "apps/*"          # deployable applications
  - "packages/*"      # shared libraries
  - "tooling/*"       # eslint/tsconfig/build config packages
  - "!**/test/**"     # exclude folders with !

# pnpm v9.5+ catalogs — shared dependency versions
catalog:
  react: ^18.3.1
  react-dom: ^18.3.1
  typescript: ^5.6.0
  vitest: ^2.1.0

# Named catalogs for variant versions
catalogs:
  react17:
    react: ^17.0.2
    react-dom: ^17.0.2

# Patch a third-party package's published manifest
packageExtensions:
  "some-broken-lib@*":
    peerDependencies:
      react: "*"

# Control which packages can run install scripts
onlyBuiltDependencies:
  - esbuild
  - sharp

The conventional layout splits packages by role: apps/* for things you deploy, packages/* for libraries, and a folder like tooling/* for internal config packages. The exact names do not matter to pnpm — only the globs do. Negation patterns (!path/**) exclude matched folders, useful when a test fixture folder contains a package.json that should not become a real workspace member.

See the package.json fields reference for the manifest fields that get pinned per-package and which migrate cleanly to a workspace setup.

package.json in a pnpm workspace (root vs project)

A pnpm workspace has one root package.json plus one per child package. The root manifest is the place for the package manager pin, shared devDependencies, repo-wide scripts, and metadata that applies to every package. Each child manifest describes one package: its name, its own dependencies, its scripts.

{
  "name": "@org/monorepo-root",
  "private": true,
  "packageManager": "pnpm@9.15.0",
  "engines": {
    "node": ">=20.0.0",
    "pnpm": ">=9.0.0"
  },
  "scripts": {
    "build": "pnpm -r run build",
    "dev": "pnpm -F web dev",
    "test": "pnpm -r run test",
    "lint": "pnpm -r run lint",
    "typecheck": "pnpm -r run typecheck",
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "pnpm -r build && changeset publish"
  },
  "devDependencies": {
    "@changesets/cli": "^2.27.0",
    "typescript": "catalog:",
    "prettier": "^3.3.0"
  }
}

private: true on the root is mandatory — without it, pnpm refuses to install. The flag signals the root is a workspace container, not a publishable package. packageManager pins the pnpm version using Corepack (Node.js 20+ ships with Corepack), so every contributor and CI runs the same release. Child package.json files look like normal manifests with one twist: internal dependencies use the workspace: protocol covered in the next section. Set private: true on any child you do not intend to publish to npm.

Internal dependencies with workspace: protocol

The workspace: protocol is what makes a pnpm monorepo work. When a child package depends on another workspace package, you write the dependency version as workspace:^ instead of a semver range. pnpm resolves the dependency to the local package via a symlink — no install, no registry call, edits take effect immediately.

{
  "name": "@org/web",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@org/ui": "workspace:^",
    "@org/utils": "workspace:^",
    "@org/db-client": "workspace:*",
    "next": "catalog:",
    "react": "catalog:",
    "react-dom": "catalog:"
  },
  "devDependencies": {
    "@org/tsconfig": "workspace:*",
    "@org/eslint-config": "workspace:*",
    "typescript": "catalog:"
  }
}

Three flavors with different publish-time behavior:

  • workspace:* — at publish, replaced with the exact current version. Use for internal-only packages or tight coupling.
  • workspace:^ — at publish, replaced with a caret range against the current version (e.g., ^1.2.3). The standard choice for published packages — allows consumers to take patch and minor updates.
  • workspace:~ — at publish, replaced with a tilde range (e.g., ~1.2.3). Patch updates only — useful for packages where minor versions break consumers.

You can also pin explicitly: workspace:1.2.3 fails the install if the local package is not exactly that version — useful for catching accidental version drift in CI. Without the protocol, pnpm will not link sibling packages by name; the protocol makes intent explicit and is required for publish-time rewriting. Treat workspace: as the only valid way to reference an internal package.

.npmrc settings that matter for monorepos

.npmrc at the workspace root controls pnpm install behavior. The defaults in v9 are sensible for most projects — auto-install-peers=true and dedupe-peer-dependents=true are on by default — but a few settings are worth knowing.

# .npmrc at workspace root

# Auto-install peer deps (default: true in v9)
auto-install-peers=true

# Deduplicate peers across the workspace (default: true in v9)
dedupe-peer-dependents=true

# Link workspace packages by name even without workspace: protocol
# (default: true — leave on)
link-workspace-packages=true

# Run install scripts only for listed packages
# Replaces the older "ignore-scripts" hammer
# Better to list trusted builders explicitly:
# onlyBuiltDependencies in pnpm-workspace.yaml

# Hoist patterns — selective alternative to shamefully-hoist
public-hoist-pattern[]=*types*
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

# Treat unmet peer deps as warnings, not errors (default: false)
strict-peer-dependencies=false

# Fail install if lockfile is out of date (use in CI)
frozen-lockfile=true

# Registry mirror for private packages
@org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

# Network and retry behavior
network-concurrency=16
fetch-retries=3

link-workspace-packages defaults to true and should stay on. With it off, even workspace: protocol dependencies get installed from the registry — defeating the point of a monorepo.

public-hoist-pattern is the targeted alternative to shamefully-hoist=true — list patterns that need to be visible at the top of node_modules (typically @types/*, eslint*, and prettier) instead of flattening every package. For dependency updates in a pnpm workspace, both Dependabot and Renovate understand pnpm-lock.yaml and the workspace: protocol.

pnpm overrides, hoist patterns, and dedupe rules

When a transitive dependency causes problems — a security advisory, a known bug, an incompatible peer — pnpm gives you three knobs to force resolution: overrides,packageExtensions, and hoist patterns. Each lives in a different file and solves a different class of problem.

{
  "name": "@org/monorepo-root",
  "private": true,
  "pnpm": {
    "overrides": {
      "lodash": "^4.17.21",
      "ws@<8.17.1": "^8.17.1",
      "postcss@<8.4.31": "^8.4.31"
    },
    "patchedDependencies": {
      "react@18.3.1": "patches/react@18.3.1.patch"
    },
    "peerDependencyRules": {
      "ignoreMissing": ["@babel/*"],
      "allowedVersions": {
        "react": "18 || 19"
      }
    }
  }
}
  • pnpm.overrides — pin any transitive package to a specific version across the whole workspace. The version-range key (e.g., ws@<8.17.1) restricts the override to vulnerable versions only, leaving newer ones untouched. The standard fix for npm audit advisories.
  • pnpm.patchedDependencies — point at a patch file produced by pnpm patch. The patch is reapplied on every install. Use for short-lived workarounds while waiting for upstream fixes.
  • pnpm.peerDependencyRules — quiet noisy peer warnings from third-party packages with overly strict peer ranges (e.g., a library that declares react: ^17 but works fine with 18 and 19).

packageExtensions in pnpm-workspace.yamlis different — it patches a third-party package's manifest at install time to add missing peer or dependency entries. Workspace-wide deduplication is automatic in v9 — dedupe-peer-dependents=true ensures the same peer version is shared across packages that pull it in, instead of installing copies per consumer.

pnpm publishConfig and changesets for releases

Publishing packages from a pnpm workspace involves two configurations: per-package publishConfig in each child package.json, and a release tool (most commonly changesets) to coordinate version bumps and changelogs across packages.

{
  "name": "@org/ui",
  "version": "0.3.2",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./styles.css": "./dist/styles.css"
  },
  "files": ["dist", "README.md"],
  "publishConfig": {
    "access": "public",
    "registry": "https://registry.npmjs.org"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  },
  "dependencies": {
    "@org/utils": "workspace:^"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts"
  }
}

publishConfig.access must be public for scoped packages (@org/...) on the free npm tier — without it, pnpm publish fails with a permission error. Unscoped packages default to public. publishConfig.registry targets a non-default registry per-package — useful when some packages go to public npm and others to GitHub Packages or a private registry.

Changesets workflow: contributors run pnpm changeset to record an intent-to-release with a semver bump and changelog entry. At release time, pnpm changeset versionconsumes the recorded changesets, bumps each package's version, and rewrites workspace: protocols to concrete ranges in the published manifests. Then pnpm -r publish ships every non-private package whose version changed, in topological order.

pnpm filter syntax for selective scripts

The --filter flag (short: -F) is how you target a subset of workspace packages for any pnpm command. It accepts package names, name globs, path patterns, and graph operators that include dependencies or dependents.

# By exact name
pnpm --filter web dev
pnpm -F @org/ui build

# By glob
pnpm -F "@org/*" test
pnpm -F "*-utils" lint

# By path (relative to workspace root)
pnpm -F "./apps/**" build

# Include dependencies (... prefix)
pnpm -F ...@org/web build      # build @org/web AND everything it depends on

# Include dependents (... suffix)
pnpm -F @org/utils... test     # test @org/utils AND everything depending on it

# Both directions
pnpm -F ...@org/web... typecheck

# Changed packages since a git ref
pnpm -F "[main]" build         # packages changed since main
pnpm -F "[origin/main]" test

# Combine filters (union)
pnpm -F web -F api dev

# Exclude packages with !
pnpm -F "!@org/legacy" build

# Parallel execution with concurrency cap
pnpm -r --parallel --workspace-concurrency=4 run build

The graph operators describe slices of the dependency graph. Prefix ...means "include everything this package transitively depends on"; suffix ...means "include everything that transitively depends on this package". The [git-ref] filter is what CI pipelines use to build only changed packages: pnpm -F "[origin/main]" build resolves to the set of packages whose files changed since origin/main. Combine with ... to also include dependents that might have broken.

For task orchestration beyond what filters provide — caching, remote execution, dependency-aware task graphs across packages — pair pnpm with Turborepo config or Nx monorepo config. pnpm handles installs and linking; Turbo or Nx handles task running.

pnpm vs npm vs Yarn workspaces: when to switch

All three major package managers support workspaces, and the feature surface has converged. The differences worth knowing fall into four categories: file layout, disk efficiency, hoisting behavior, and feature depth.

Concernnpm workspacesYarn workspacespnpm workspaces
Workspace declarationworkspaces array in root package.jsonworkspaces array in root package.jsonSeparate pnpm-workspace.yaml
node_modules layoutFlat (hoisted)Flat (classic) or PnP (Berry)Strict nested (symlinks to content-addressable store)
Disk usage (10 projects, same deps)10x10x (classic) / 1x (PnP)~1x — hardlinks dedupe globally
workspace: protocolNot supported (uses semver only)Supported (Yarn 2+)Required — first-class
Catalogs / shared versionsNot built-inVia constraints (Berry)Built-in (v9.5+)
Filter syntax--workspace (limited)workspaces foreach--filter with graph operators
Strict undeclared depsNo (hoisted)Yes (PnP) / No (classic)Yes by default
Install speed (cold)Baseline~Same~2x faster (parallel + hardlink)

Choose pnpm for medium-to-large monorepos (5+ packages), CI pipelines where install time matters, projects with many local packages depending on each other, and any case where strict dependency resolution catches bugs that flat hoisting hides. Stay on npm for small two-package setups where the overhead of a separate config file outweighs the gains. Choose Yarn Berry if PnP's zero-install model fits your CI strategy.

See the JSON config files overview for how package.json fits into the broader landscape of JSON-driven tooling config across Node.js projects.

Key terms

pnpm-workspace.yaml
The YAML file at the repo root that declares which folders count as workspace packages, plus pnpm-specific config like catalogs, packageExtensions, and onlyBuiltDependencies. Required for any pnpm monorepo.
workspace: protocol
The dependency-range prefix that tells pnpm to link to a local workspace package by name. Three forms — workspace:* (exact), workspace:^ (caret), workspace:~ (tilde) — each producing a different concrete range at publish time.
catalog (pnpm v9.5+)
A named map of package-name to version in pnpm-workspace.yaml. Child packages reference an entry as "package": "catalog:" to inherit the workspace-pinned version, eliminating drift across packages.
filter (--filter / -F)
pnpm's package-targeting flag. Accepts names, globs, paths, and graph operators (...pkg for dependencies, pkg... for dependents). The [git-ref] form filters by files changed since a git reference.
content-addressable store
pnpm's global on-disk cache at ~/.local/share/pnpm/store (location varies). Packages are stored once globally and hardlinked into each project's node_modules, producing 50%+ disk savings versus npm's copy-per-project model.
shamefully-hoist
An .npmrc setting that flattens every transitive dependency into the top of node_modules, matching npm/Yarn classic layout. Hides undeclared dependencies — use public-hoist-pattern for targeted hoisting instead when possible.

Frequently asked questions

Why does pnpm use a YAML file instead of package.json workspaces?

pnpm splits the workspace declaration into a dedicated pnpm-workspace.yaml file because the workspace config grew beyond a simple packages array. The YAML file holds the packages glob list plus pnpm-specific fields that have no place in package.json — catalog and catalogs for shared dependency versions (added in 9.5), packageExtensions for patching third-party manifests, public-hoist-pattern, ignored-built-dependencies, and onlyBuiltDependencies. npm and Yarn keep workspaces in the root package.json because their feature surface is smaller. pnpm could have stuffed everything under a pnpm key in package.json, but a separate YAML file keeps the root manifest readable and lets workspace settings live with other pnpm config like .npmrc. The root package.json in a pnpm workspace still exists — it pins the package manager version, holds devDependencies that apply across packages, and runs top-level scripts.

What does the workspace: protocol do in package.json?

The workspace: protocol tells pnpm that a dependency lives inside the current workspace, not on the npm registry. When you write "@org/utils": "workspace:^" in apps/web/package.json, pnpm links apps/web/node_modules/@org/utils directly to packages/utils on disk via a symlink — no install, no registry fetch, and edits to packages/utils show up instantly. The three forms have different publish behavior: workspace:* publishes as the exact current version, workspace:^ publishes as a caret range against the current version, and workspace:~ publishes as a tilde range. Without the protocol, pnpm cannot tell whether @org/utils means the local package or a registry package of the same name, and pnpm publish would push a manifest with workspace:^ literal text — which the registry would reject. The protocol is replaced with a concrete range only at publish time.

How do I run a script in only one workspace package?

Use the --filter flag (short: -F) with the package name. pnpm --filter web dev runs the dev script in the package whose package.json name field is "web", regardless of its folder path. Globs work too: pnpm --filter "@org/*" build builds every package under the @org scope. To include dependencies, append a caret: --filter ...web includes web and everything web depends on; --filter web... includes web and everything that depends on web. Path-based filtering is also supported: --filter ./apps/web matches by folder. Combine with --parallel to run scripts concurrently, or with --workspace-concurrency=N to cap parallelism. Without --filter, pnpm run script_name executes in the package at the current working directory; pnpm -r run script_name (recursive) runs it in every workspace package that defines it.

How do I share devDependencies across packages?

You have three options that scale with rigor. The simplest is to put shared devDependencies (TypeScript, ESLint, Prettier, Vitest) in the root package.json — pnpm hoists them into the root node_modules, and any package can resolve them through Node.js parent-lookup. This works but it is implicit: a child package using TypeScript without declaring it depends on the root install. The second option is pnpm catalogs (v9.5+) — define versions once in pnpm-workspace.yaml under catalog: and reference them with "typescript": "catalog:" in every package.json. Catalogs make the shared version explicit while keeping it in one place. The third is to create an internal config package (e.g., @org/tsconfig) that re-exports configs and lists the actual devDependency — every child package depends on @org/tsconfig and gets the tools transitively.

What is shamefully-hoist and when should I use it?

shamefully-hoist=true in .npmrc tells pnpm to flatten every transitive dependency into the top of node_modules, matching the layout npm and Yarn classic produce. By default pnpm keeps node_modules strict — each package only sees its declared dependencies, and transitive packages live nested. The name is intentional: hoisting hides missing peer or undeclared dependencies, so a package that imports lodash without listing it in dependencies still works, masking a real bug. Use shamefully-hoist only when a third-party tool cannot work with strict node_modules — older versions of some Next.js plugins, Jest configs that assume flat layout, or Electron tooling. Always treat it as a temporary workaround and try public-hoist-pattern instead, which lets you hoist specific packages by pattern (public-hoist-pattern[]=*types* hoists every @types package) without disabling strictness everywhere.

How does pnpm handle peer dependencies in workspaces?

pnpm installs peer dependencies automatically since v8 (auto-install-peers=true is the default), and as of v9 the dedupe-peer-dependents setting (also default true) prevents duplicate installs across the workspace. In practice this means: when a workspace package declares "peerDependencies": { "react": "^18" }, pnpm finds the react version that the consuming package depends on and links the peer-dependent package against it. If two packages in the workspace pull in different React majors, pnpm warns and installs both. The strict-peer-dependencies setting (default false) controls whether unmet peers cause an install failure or just a warning. For libraries published from a workspace, declare React/Vue/Svelte as peerDependencies, not dependencies — otherwise consumers get duplicate copies of the framework, which breaks hooks and context.

Can I publish only some workspaces to npm?

Yes. Add "private": true to the package.json of any workspace you do not want published — pnpm publish skips them. For workspaces you do publish, pnpm publish -r (recursive) publishes every non-private package in the workspace, replacing workspace: protocol ranges with concrete semver and computing a publish-ready manifest. To publish only one package: pnpm --filter @org/utils publish. The publishConfig field controls per-package npm settings — set "publishConfig": { "access": "public" } for scoped packages on the free npm tier, or "publishConfig": { "registry": "https://npm.pkg.github.com" } to publish to GitHub Packages instead. For coordinated releases with changelogs, most monorepos use changesets (@changesets/cli) on top of pnpm — pnpm changeset publishes the version bumps and changelogs in lockstep across packages.

How do I migrate from npm workspaces to pnpm?

The migration is four steps. First, install pnpm globally (npm install -g pnpm or use Corepack) and delete node_modules plus package-lock.json from the repo root. Second, move the workspaces array from the root package.json into a new pnpm-workspace.yaml — change the JSON array to a YAML packages: list. Third, rewrite internal dependencies: anything that npm workspaces resolved by name (e.g., "@org/utils": "*") should use the workspace: protocol explicitly — "@org/utils": "workspace:^". This is critical because pnpm will not silently link a registry-style range to a local package. Fourth, run pnpm install — pnpm creates pnpm-lock.yaml, sets up the strict node_modules layout, and reports any missing peer dependencies that npm was hiding through flat hoisting. Expect 30–50% disk savings versus the npm install via pnpm hardlinks from the content-addressable store.

Further reading and primary sources