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.
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.jsonnameand mapsworkspace:*/workspace:^protocol ranges to local paths, so internal dependencies resolve to on-disk symlinks rather than the registry. - CLI precedence.
--filteroverrides a plain rootpnpm run. If no package matches, pnpm exits0unless 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 (
preinstall→install→postinstall→build) 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
- Using pnpm --filter for Targeted Builds — the focused task-runner walkthrough, including diagnosing
ERR_PNPM_FILTER_NO_MATCH. - Running Scripts Across Workspaces with pnpm — orchestrating scripts across the selected package set, topologically and in parallel.
- Cross-Package Dependency Management — how the
workspace:protocol links internal packages that filters then select against. - Turborepo Pipeline Configuration — adding caching and a task pipeline on top of package-manager filtering.
- Nx Workspace Architecture — a graph-driven runner whose
--projectsflag complements pnpm filtering.