Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

pnpm Workspace Filtering

In a multi-package repository, running every script everywhere is the default and the wrong default. pnpm's --filter flag is the native answer: it resolves a workspace graph from your manifests and runs a command against exactly the package set you select — by name, by path, by directory, or by graph traversal — with no background daemon. This page explains the resolution model, the full filter syntax, change-aware CI selection, scoped publishing, and the security controls that keep filtered execution from leaking packages or tokens. It sits under Monorepo Architecture & Orchestration, and the focused task-runner walkthrough lives in Using pnpm --filter for Targeted Builds.

Filter selectors mapping to an affected package set A filter selector resolves against the workspace graph, then traversal operators expand the selection upstream to dependencies or downstream to dependents. Selector plus traversal yields the affected set Filter selector @org/core ./packages/ui Workspace graph names + workspace: links resolved Affected set tasks run here only ...pkg (upstream) target + its dependencies build inputs first "what I need" pkg... (downstream) target + its dependents consumers to retest "what I break"
A selector picks a starting package; the ellipsis operators expand it upstream to dependencies or downstream to dependents.

How pnpm Resolves the Workspace Graph

pnpm builds a directed acyclic graph from pnpm-workspace.yaml plus the name field of each package's package.json. --filter then selects a subgraph and runs your command only inside it, so unrelated packages are never touched.

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
  - '!**/test/**'

The resolution rules worth internalizing:

  • Graph construction. pnpm reads every package.json name and maps workspace:* / workspace:^ protocol ranges to local paths, so internal dependencies resolve to on-disk symlinks rather than the registry.
  • CLI precedence. --filter overrides a plain root pnpm run. If no package matches, pnpm exits 0 unless you pass --fail-if-no-match (pnpm v8+), which you should in CI so a typo'd filter fails loudly instead of silently running nothing.
  • Lifecycle order. Filtering scopes which packages run, not how — standard lifecycle order (preinstallinstallpostinstallbuild) is preserved within the selected set.

This native graph traversal is what makes pnpm filtering cheap, and it builds on the workspace fundamentals in Workspace Configuration Deep Dive.

Filter Syntax & Dependency Scoping

Filter syntax operates entirely at the CLI layer. Always single-quote patterns containing * or ... so the shell does not expand them before pnpm parses them.

# 1. Exact name match
pnpm --filter @my-org/design-system build

# 2. Path-based match (relative to workspace root)
pnpm --filter './packages/ui-lib' build

# 3. Upstream traversal (target + all dependencies)
pnpm --filter '...@my-org/api' build

# 4. Downstream traversal (target + all dependents)
pnpm --filter '@my-org/core...' test

# 5. Multi-filter chaining (union of two selections)
pnpm --filter @my-org/core --filter @my-org/utils lint

The two ellipsis forms encode opposite intents. ...pkg means "everything pkg needs" — use it before a build so dependencies compile first. pkg... means "everything that needs pkg" — use it before a test run so you retest every consumer that a change to pkg could break.

While Turborepo Pipeline Configuration and Nx Workspace Architecture add remote caching and distributed task graphs on top, pnpm's zero-daemon filtering is the better fit for strictly package-focused repos, memory-constrained CI runners, and any pipeline that wants deterministic, package-manager-native execution without an extra orchestrator.

Change-Aware Selection in CI

The most valuable filter in CI is not a name — it is a diff. pnpm can select packages directly from a git range, which keeps pipeline time proportional to the change rather than the repo. The mechanics and edge cases of this pattern are covered in Using pnpm --filter for Targeted Builds.

# Build everything changed since main, plus their dependencies
pnpm --filter '...[origin/main]' run build

For pipelines that need an explicit, auditable package list — for example to fan out a matrix — compute it from the diff and validate it against an allowlist before executing:

#!/usr/bin/env bash
set -euo pipefail

# 1. Identify changed top-level directories since the base ref
CHANGED_DIRS=$(git diff --name-only origin/main...HEAD | awk -F'/' '{print $1"/"$2}' | sort -u)

# 2. Map directories to pnpm workspace package names
FILTER_ARGS=""
for dir in $CHANGED_DIRS; do
  PKG=$(pnpm list --json --filter "./$dir" 2>/dev/null | jq -r '.[0].name // empty')
  if [[ -n "$PKG" ]]; then
    FILTER_ARGS+="--filter $PKG "
  fi
done

# 3. Reject anything outside the organization scope
ALLOWLIST_REGEX="^@my-org/"
if [[ -n "$FILTER_ARGS" && ! "$FILTER_ARGS" =~ $ALLOWLIST_REGEX ]]; then
  echo "Filter output contains unauthorized scopes. Aborting."
  exit 1
fi

# 4. Execute only the affected set
if [[ -n "$FILTER_ARGS" ]]; then
  pnpm $FILTER_ARGS lint
  pnpm $FILTER_ARGS test
else
  echo "No workspace packages affected. Skipping."
fi

A matrix job then consumes the detected list:

jobs:
  workspace-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJSON(needs.detect.outputs.packages) }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter ${{ matrix.package }} test

fetch-depth: 0 is mandatory — change-aware filtering needs full git history to compute the diff against the base ref. The --frozen-lockfile install keeps resolution deterministic; pair it with the integrity practices in Lockfile Management Strategies.

Running Tasks Across the Selected Set

Once a filter selects packages, you orchestrate scripts across them. pnpm runs scripts in topological order so a dependency's build finishes before a dependent's, and --parallel lifts that ordering for tasks that are genuinely independent (linting, type-checking). The full matrix of root-level versus per-package scripts — and when to reach for each — is covered in Running Scripts Across Workspaces with pnpm.

# Topological: respects the dependency graph (default)
pnpm --filter '...@my-org/api' run build

# Parallel: ignore order, run independent tasks at once
pnpm --filter '@my-org/*' --parallel run lint

Because filtering only changes which internal packages execute and not how they link, it composes cleanly with the broader Cross-Package Dependency Management model — the workspace: protocol still resolves dependents to local code regardless of how you filter.

Scoped Publishing & Release Workflows

Combine --filter with a versioning tool to enforce scoped, auditable releases. Never run pnpm publish without a dry run and a passing validation gate first.

# 1. Pre-flight validation across the org scope
pnpm --filter '@my-org/*' run lint:strict
pnpm --filter '@my-org/*' run test:ci

# 2. Version bump (using @changesets/cli)
npx changeset version
pnpm install --frozen-lockfile

# 3. Filtered publish to the private registry
pnpm --filter '@my-org/*' publish --access restricted --tag next
Control Implementation
Private package isolation Set "private": true in the root package.json; publish only explicit workspace packages.
Auth token scoping Use an NPM_TOKEN limited to publish scope; never use a broad read/write token in CI.
Dry-run validation Run pnpm --filter <scope> publish --dry-run before any production push.
Semver enforcement Use Changesets or release-please to generate CHANGELOG.md and prevent manual version drift.

Common Pitfalls & Security Anti-Patterns

Mistake Impact Remediation
Unquoted globs or ellipsis The shell expands ... or * before pnpm sees it, causing ENOENT or no-match. Single-quote every filter: --filter '...@scope/pkg'.
Assuming hooks are skipped --filter scopes packages but still runs pre/post lifecycle hooks. Pass --ignore-scripts when hooks must be bypassed in CI.
Missing fetch-depth: 0 Change-aware filters compute an empty or wrong diff on a shallow clone. Set fetch-depth: 0 on checkout so the base ref is available.
Hardcoded CI package lists Wasted compute, stale caches, missed dependency impacts. Use --filter '...[base]' or diff-driven detection.
Unscoped .npmrc auth Filtered publish leaks internal packages to public npm. Scope tokens via @org:registry= and set publishConfig.access=restricted.

Frequently Asked Questions

How does pnpm --filter differ from a Turborepo or Nx task runner? pnpm filtering resolves the workspace graph natively at the package-manager layer with no background daemon. It gives lightweight, deterministic execution but does not provide built-in remote caching or distributed task orchestration, which are the reasons teams adopt a dedicated runner on top.

Can I safely use pnpm --filter with private npm registries? Yes. pnpm honors per-workspace .npmrc settings, so scope the registry and auth token to your organization with @org:registry= and validate filter output against an allowlist before any install or publish runs.

Why does --filter '...' sometimes include packages I did not expect? The traversal operators are transitive. ...pkg pulls in the full upstream dependency chain and pkg... pulls in the full downstream dependent chain, so a single workspace link can drag in many packages. Drop the operators and use an exact name to restrict execution to one package.

How do I make a filter fail loudly when it matches nothing? Add --fail-if-no-match (pnpm v8+). By default a no-match filter exits 0 and runs nothing, which hides typos; in CI that silence looks like a green build over zero work.

Related

Monorepo Architecture & Orchestration