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.
Filtering is only as good as the workspace graph it selects against, so it helps to understand how pnpm builds that graph. pnpm reads the package globs in pnpm-workspace.yaml, discovers every package, and links their internal dependencies through the content-addressed store, forming a directed graph of who depends on whom. Every --filter expression is a query over that graph — a way of naming a subset by identity, by change, or by graph relationship — so an accurate graph is the precondition for every targeted operation, from a scoped build to a change-aware CI run.
Because the graph is derived from declared dependencies, an undeclared internal import is invisible to it, and that invisibility is the one thing that makes filtering unsafe. If package A imports package B through a deep relative path instead of a workspace dependency, pnpm does not see the edge, so a filter that selects B's dependents will miss A — and a change to B can ship A untested. Keeping internal dependencies declared through the workspace: protocol is therefore not just tidiness but the foundation that makes every filter trustworthy.
The workspace graph pnpm builds is more than a list of packages; it is a directed structure of dependency edges that every filter queries. When pnpm reads the package globs and links internal dependencies through the store, it records which package depends on which, so a selector like the dependents form can walk from a changed package outward to everything that imports it. The accuracy of that structure is what makes filtering trustworthy — a filter is only as complete as the edges the graph contains, which is why declared internal dependencies matter as much for correctness as for tidiness.
This graph is also what lets pnpm run tasks in the right order across a filtered set. Because the graph encodes dependency direction, a filtered build sequences packages topologically — a package builds after the local packages it imports — and parallelizes independent packages. A filter therefore does not just select a subset; it selects a correctly-orderable subgraph, which is why a filtered build behaves like a coherent sub-build of the whole workspace rather than an arbitrary loop over packages.
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.
The filter selectors compose a small but expressive language for naming subsets of the graph. A bare --filter pkg selects one package; the trailing-ellipsis --filter pkg... adds its dependents (packages that import it); the leading-ellipsis --filter ...pkg adds its dependencies (packages it imports); and the bracket form --filter '...[origin/main]' selects everything changed since a git ref plus its dependents. Combining them — --filter ...pkg^... and similar — lets you name exactly the slice a task needs, whether that is a package plus its build prerequisites or a package plus everything its change could affect.
Choosing the right selector is a correctness decision, not a convenience. For a build you usually want the target plus its dependencies, so the prerequisites are built first; for a test run you want the target plus its dependents, so consumers of a change are validated; for CI you want changed packages plus dependents, which is the affected set. Using the wrong direction — testing only dependencies instead of dependents, say — produces a run that looks scoped but silently omits the packages a change could break.
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.
Once a filter has named a set, pnpm runs a task across it with the same graph awareness that selected it, which matters for ordering. Running a build across a filtered set respects topological order, so a package builds after the local packages it depends on, and --parallel runs independent packages concurrently where the graph permits. This is why a filtered build is not just a scoped loop but a correctly-ordered sub-build: the same dependency graph that chose the packages also sequences them.
Combining a filter with a task runner layers caching on top of selection, which is where the real speed comes from. The filter removes packages the change cannot reach; the runner replays any survivor whose inputs already have a cached result. On a large workspace this means a change to one package runs that package and its dependents, and even among those, only the ones whose inputs actually changed are recomputed — the rest are restored from cache, local or remote.
Argument passing across a filtered set follows the double-dash convention, forwarding flags to each package's script: pnpm --filter '...[origin/main]' run test -- --coverage runs the affected packages' tests with coverage. A flag like --coverage sensibly applies to every invocation, but a positional argument that only makes sense for one package does not, which is a reason to target a single package explicitly when the argument is package-specific. Combining the change-aware filter with forwarded flags is what lets one command run exactly the affected tests in exactly the mode CI needs, without a bespoke script per scenario.
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. |
Filtering also scopes publishing, so a release can target exactly the packages that changed rather than republishing the whole workspace. Selecting changed packages since the last release tag and publishing only those keeps version churn honest — a package that did not change does not get a needless version bump — and it composes with independent versioning, where each package moves on its own cadence. The filter names the release set; the versioning tool computes each selected package's next version from its changes.
The correctness concern in scoped publishing is the same dependents question in a new guise. When a package changes, packages that depend on it may need republishing too if they inline or re-export its API, so the release filter often needs the dependents form to avoid shipping a package against a stale internal dependency. Getting this wrong publishes a consumer package that references a version of its dependency that no longer matches, which is exactly the kind of subtle, cross-package breakage a monorepo is supposed to prevent.
Scoped publishing uses the same filter machinery to release only what changed, which keeps version churn honest. Selecting packages changed since the last release tag — usually with the dependents form, so a package that re-exports a changed internal API is republished too — and publishing only that set means a package that did not change does not get a needless version bump. This composes with independent versioning, where the release tool computes each selected package's next version from its own changes, so the filter names the release set and the versioning tool sizes each bump.
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. |
The most consequential filtering pitfall is a selector that omits dependents, because it produces a run that succeeds while silently skipping the packages a change could break. Testing only the changed package, rather than the changed package plus its dependents, ships its consumers untested; the fix is to prefer the dependents-inclusive form wherever a change could propagate. A close second is a shallow clone that makes the change base unreachable, which forces the filter to select everything or, in some configurations, nothing — both of which defeat the purpose.
The security anti-pattern specific to filtering is running scripts across a set that includes untrusted packages. Fanning a script over the workspace runs each package's scripts, so a compromised package in the set executes its lifecycle code with the job's privileges. Keeping the workspace to reviewed, first-party packages, running installs with ignored scripts, and verifying the selected set with a dry-run before a scoped CI run keeps that surface controlled. A filter is a claim about which packages matter for an operation, and like any claim it should be verified rather than trusted blindly.
Change-aware selection in CI
The highest-value filter in practice is the change-aware one, because it is what keeps monorepo CI proportional to the change rather than the repo. The bracket selector ...[origin/main] expands to every package changed since the base branch plus everything that depends on them, which is exactly the set that needs building and testing after a change. A one-line edit to a leaf library then runs that library and its dependents, and nothing else, turning a pipeline whose cost grew with the repo into one whose cost tracks the diff.
The precondition, as always, is git history and an accurate graph. The base ref must be reachable — a shallow clone that lacks the merge base forces the filter to select everything, silently erasing the speed-up — and the dependency edges must be declared so the dependent traversal finds the real consumers. Get both right and change-aware filtering composes with a task runner's caching to make even the affected set fast: the filter removes packages the change cannot reach, and the cache replays any survivor whose inputs are unchanged, on this runner or another.
Filtering safely: the security and correctness pitfalls
Filtering has failure modes that are quiet precisely because a scoped run still succeeds — it just does less than you think. The most dangerous is a too-narrow selector that omits dependents, so a change ships without its consumers being tested; the fix is to prefer the dependents-inclusive form (...pkg or ...[ref]) wherever a change could propagate. The next is a shallow clone that makes the change base unreachable, which either runs everything (slow) or, worse in some configurations, runs nothing; fetching adequate history prevents it.
There is also a supply-chain dimension to running tasks across a filtered set. Fanning a script across many packages runs each package's scripts, so a compromised or untrusted package in the set executes its lifecycle code; running installs with ignored scripts and keeping the workspace to reviewed, first-party packages keeps that surface controlled. The through-line is that a filter is a claim about which packages matter for an operation, and like any claim it should be verified — print the selected set with a dry-run and confirm it matches the change before trusting a scoped CI run to have covered everything it needed to.
Filter patterns for common workspace tasks
The filter language maps cleanly onto the everyday operations a monorepo runs, and knowing the idiomatic pattern for each removes guesswork. To build one package and its prerequisites, select it with its dependencies so they build first. To test the impact of a change, select the changed package with its dependents so consumers are validated. To run CI on a pull request, select everything changed since the base with dependents — the affected set. To lint or format, no filter is needed since those are usually whole-repo and cheap. Matching the pattern to the task's actual dependency direction is what keeps each operation both fast and correct.
These patterns also compose with path and glob selectors for coarser slicing. Filtering by directory (--filter './packages/ui/**') selects a subtree regardless of the dependency graph, useful for team-owned areas; combining a path filter with a change filter narrows to changed packages within a subtree, useful when different parts of a large monorepo have different pipelines. The practical skill is reaching for the graph-relationship selectors when correctness depends on dependents or dependencies, and the path selectors when you simply want a coarse, ownership-based slice — and verifying either with a dry-run before trusting it in CI.
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.
What's the difference between pkg... and ...pkg?
pkg... selects the package plus its dependents (packages that import it) — the right set for testing a change. ...pkg selects the package plus its dependencies (packages it imports) — the right set for building prerequisites first. The ellipsis direction chooses which way you traverse the graph.
Why does my change filter sometimes select everything?
Usually a shallow clone with no reachable base commit, so pnpm cannot compute the diff and falls back to all packages. Fetch full history and pass an explicit base ref. A change to a root-level file also legitimately selects everything.
Is it safe to run scripts across a filtered set?
Only if the packages are trusted, because fanning a script runs each package's lifecycle scripts. Keep the workspace to reviewed first-party packages, run installs with --ignore-scripts, and verify the selected set with a dry-run before a scoped CI run.
Does a filtered build still respect dependency order?
Yes. pnpm sequences a filtered build topologically, so each package builds after the local packages it depends on, and --parallel runs independent packages concurrently. The graph that selected the packages also orders them, so a filtered build is a correctly-ordered sub-build.
How do I publish only the packages that changed?
Filter to packages changed since the last release tag, usually with the dependents form so consumers that re-export a changed API are republished too, and let independent versioning compute each selected package's next version. That keeps version churn honest and avoids shipping against a stale internal dependency.
What's the most useful pnpm filter to know?
The change-aware --filter '...[origin/main]', which selects packages changed since the base plus their dependents — the affected set that keeps CI proportional to the change. It needs full git history so the base resolves and an accurate graph so the dependent traversal is complete.
What's the difference between pkg... and ...pkg?
pkg... selects the package plus its dependents (packages that import it) — the right set for testing a change's impact. ...pkg selects the package plus its dependencies (what it imports) — the right set for building prerequisites first. The ellipsis direction chooses how you traverse the graph.
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.