Developer Productivity

pnpm Workspaces: Filters, Catalogs, and CI Caching

Understand pnpm workspace filters, catalog protocol for shared dependencies, and CI caching strategies that reduce install times by avoiding unnecessary work.

Mohammed Saqib8 min read
A clean and stylish workspace featuring dual monitors, a lamp, and office supplies.
Photo by Lee Campbell on Pexels · Pexels License

If your monorepo build times are growing linearly with the number of packages, or you're manually keeping dependency versions in sync across a dozen package.json files, you're leaving time on the table. pnpm workspaces offer three features that directly address these pain points: workspace filters for selective execution, the catalog protocol for shared external dependency versions, and CI caching strategies that actually hit. This article assumes you already have a basic pnpm workspace set up; we focus on the mechanics that make a difference in daily workflow.

Why pnpm Workspaces Matter for Monorepos

Monorepos are a trade-off: you get atomic commits and shared tooling, but you pay for it with slower installs and irrelevant builds. pnpm workspaces handle the install side with a content-addressable store that deduplicates dependencies across packages, avoiding the node_modules bloat that plagues npm and Yarn Classic. But the real wins come from features that most teams underuse. Filters let you run commands only on the packages that actually changed. The catalog protocol eliminates the manual version sync problem. And CI caching, when done right, makes pnpm install a sub-second operation on most runs. These aren't edge cases—they're the difference between a monorepo that feels fast and one that feels like a bottleneck.

Workspace Filters: Picking the Right Packages

The --filter flag is your scalpel for monorepo task execution. It accepts patterns that select packages by name, directory path, or change set. The syntax is compact but expressive.

# Run build only in @my/lib
pnpm --filter @my/lib build
 
# Run test in all packages under packages/ directory
pnpm --filter "./packages/*" test
 
# Run lint only in packages that changed since origin/main
pnpm --filter "[origin/main]" lint

The real power is in the ... prefix and suffix operators. ...{@my/lib} includes @my/lib and all packages that depend on it (transitive dependents). {@my/lib}... includes the package and all its dependencies. ...{@my/lib}... includes both directions. This is critical for ensuring you don't run CI on a package that hasn't changed, but also don't miss packages that need re-testing because their dependency changed.

Combine filters with logical operators by repeating --filter. pnpm --filter @my/lib --filter @my/utils build runs in both. There's no --filter AND/OR syntax—just multiple flags.

The since filter is particularly useful for CI:

pnpm --filter "...[origin/main]" test

This runs tests only for packages that changed compared to origin/main, plus any packages that depend on them. It's the monorepo equivalent of a targeted build.

The Catalog Protocol: Shared Dependency Versions Without Repetition

Introduced in pnpm 8.x, the catalog: protocol lets you define a version once in pnpm-workspace.yaml and reference it as "some-package": catalog: in any package.json. Unlike workspace: (which links a local package), catalog: is for external dependencies. It ensures the same version of lodash, React, or any shared dependency across all packages without manually updating each one.

Define your catalog in pnpm-workspace.yaml:

packages:
  - 'packages/*'
 
catalog:
  react: ^18.2.0
  lodash: ^4.17.21
  date-fns: ^3.0.0

Then in any package's package.json:

{
  "name": "@my/app",
  "dependencies": {
    "react": "catalog:",
    "lodash": "catalog:",
    "date-fns": "catalog:"
  }
}

When you run pnpm install, the catalog entries are resolved to the versions defined in the workspace file. If you need multiple version sets (e.g., some packages use React 18, others React 17), you can use named catalogs: catalog:react17 and reference them as "react": "catalog:react17". But in practice, a single catalog for each dependency is usually enough.

The catalog protocol is documented in the pnpm catalogs documentation. It's a straightforward way to enforce a single source of truth for external dependency versions without the overhead of a custom tool.

CI Caching That Actually Hits

Caching node_modules is the most common mistake in monorepo CI. The problem is that node_modules is huge and includes many files that don't change, but the cache key is often based on package.json or pnpm-lock.yaml. If the lockfile changes, the cache misses and you reinstall everything. If the lockfile doesn't change, the cache hits and you skip the install entirely. The key is to cache the pnpm store, not node_modules directly.

The default store location is ~/.local/share/pnpm/store on Linux. Absolute paths cause cache misses when CI runners use different base directories. Workaround: set --store-dir to a relative path inside the project. For example, add a .npmrc in the project root:

store-dir=.pnpm-store

Now cache .pnpm-store and node_modules using pnpm-lock.yaml as the primary cache key. On GitHub Actions, a solid setup looks like this:

- uses: actions/cache@v3
  id: pnpm-cache
  with:
    path: |
      .pnpm-store
      node_modules
    key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-
 
- run: pnpm install --frozen-lockfile
  if: steps.pnpm-cache.outputs.cache-hit != 'true'

The restore-keys fallback to the previous lockfile hash means that even if the lockfile changed, you'll still get a partial cache hit for the store, reducing download time. The --frozen-lockfile flag ensures that if the cache is stale (e.g., cached node_modules doesn't match the lockfile), the install fails immediately rather than silently updating the lockfile. This is the same principle behind reproducible dev environments with Dev Containers and a single script—you want to catch mismatches upfront.

For more details, see pnpm's official CI caching guide.

Protocol Purpose Example Notes
workspace: Reference a local package in the monorepo "@my/lib": "workspace:*" Creates a symlink; resolves to local version. Use workspace:* to always match the local version.
catalog: Reference an external dependency with a version defined in pnpm-workspace.yaml "react": "catalog:" Resolves to the version in the catalog. Ensures consistency across packages.
link: Reference any directory (legacy) "my-pkg": "link:../my-pkg" Avoid in favor of workspace:. Doesn't follow the workspace protocol.

workspace: is for internal dependencies—packages you own and develop inside the monorepo. catalog: is for external dependencies you want to version consistently. link: is a leftover from npm's old link command; it works but doesn't integrate with the workspace graph or the catalog. You should never need it.

Failure Modes and Gotchas

Filter syntax is easy to misread. --filter "...{@my/lib}" includes the package and its dependents (packages that depend on it). --filter "...{@my/lib}..." includes the package, its dependents, and its dependencies. That's a big difference. If you run tests only on dependents, you might miss testing the package itself. If you include dependencies, you might run tests on dozens of unrelated packages. Always verify with pnpm ls --filter ... or pnpm list --filter ... before running a command.

The catalog protocol does not automatically replace catalog: references in package.json when you publish a package. If you run pnpm publish on a package that still has "react": "catalog:" in its dependencies, the published package will have a broken reference. You need to either use pnpm publish --no-git-checks (which doesn't check for catalog references) or run a prepublish script that rewrites catalog: to the actual version. The pnpm catalog docs cover this, but it's an easy pitfall to overlook.

CI cache misses happen when the lockfile changes between branches but the cache key only uses the hash of the lockfile content. Restore-keys help, but if the store is large, cache eviction can be slow. Also, avoid caching node_modules if you rely on --frozen-lockfile to catch mismatches—if the cache is stale, the install will fail, but you'll have to reinstall anyway. The better approach is to cache only the store and let node_modules be rebuilt from it. That's faster than downloading a full node_modules tarball because the store is already deduplicated and content-addressed.

Another common issue: using workspace:* for a package that hasn't been built yet. The symlink will point to the source directory, but if the package needs to be compiled, you'll get runtime errors. Combine with a build step that builds dependencies first. If you're using Git worktrees to review PRs without stashing your work, make sure your CI script handles the workspace dependency graph correctly.

Putting It All Together

Start with a clean pnpm-workspace.yaml that defines a catalog for shared external dependencies and uses workspace:* for internal packages:

packages:
  - 'packages/*'
  - 'apps/*'
 
catalog:
  react: ^18.2.0
  react-dom: ^18.2.0
  typescript: ~5.4.0

Then write CI scripts that use --filter with since to run only necessary tasks:

pnpm --filter "...[origin/main]" build
pnpm --filter "...[origin/main]" test

Cache the store with the lockfile hash as the primary key, as shown earlier. Test your setup by intentionally breaking a dependency version—change a catalog entry to a non-existent version and verify that pnpm install --frozen-lockfile fails. This ensures your caching and catalog are working as expected.

Key takeaways

  • Workspace filters with --filter "[origin/main]" and transitive operators let you run only the builds and tests that matter, reducing CI time significantly.
  • The catalog protocol (catalog:) eliminates manual version drift for external dependencies across all packages in the monorepo.
  • Cache the pnpm store (not just node_modules) using pnpm-lock.yaml as the cache key, and set store-dir to a relative path to avoid absolute path issues across runners.
  • Always use --frozen-lockfile in CI to catch stale caches and consistency issues early.
  • Prefer workspace: for internal packages and catalog: for external ones; avoid link:.

Frequently asked questions

How do I filter only changed packages in a pnpm monorepo?
Use `pnpm --filter "...{since}[origin/main]"` to list packages changed since a branch point. Combine with `--filter` for specific scopes, e.g., `pnpm --filter @my/lib --filter "...{./packages/*}" build`.
What is the difference between workspace: and catalog: protocol?
`workspace:` links a local package by path, while `catalog:` declares a dependency that can be resolved to a specific version across multiple packages without duplicating the version string. Use `catalog:` for shared external dependencies to keep one source of truth.
Why does my pnpm CI cache miss so often?
Cache misses often come from using a broad cache key (e.g., only `package.json`), platform-specific node_modules, or absolute paths in the store. Use `pnpm-lock.yaml` as the primary cache key, store the virtual store at a relative path, and set `PNPM_HOME` to a consistent CI location.
#pnpm#workspaces#monorepo#ci-caching
Share

Keep reading