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

Using pnpm --filter for Targeted Builds

A targeted build runs only the packages a change actually affects, instead of rebuilding the whole repository. pnpm's --filter flag delivers that natively, but it fails in confusing ways when the selector does not resolve: either it errors out, or worse, it silently falls back to running everywhere. This page covers the exact symptoms, the root cause, a step-by-step recovery workflow, and the CI guardrails that keep targeted builds targeted. It builds directly on pnpm Workspace Filtering.

Exact Symptoms

ERR_PNPM_FILTER_NO_MATCH  No projects matched the filters in "/repo"

Other signals that filtering has gone wrong:

  • Builds execute on packages you never named, and CI duration creeps back up toward a full-workspace run.
  • A --filter '...[origin/main]' selection resolves to zero packages even though files changed.
  • Cascading "cannot find module" failures because a targeted build skipped a dependency it needed.
Change-aware filter resolving a git range to a build set A git base ref diff yields the changed packages, the upstream operator adds their dependencies, and pnpm builds only that set. ...[origin/main] git range diff changed + deps resolved package set build runs here nothing else Full git history is required for the diff to resolve correctly.
Change-aware filtering diffs against a base ref, adds upstream dependencies, and builds only the resulting set.

Root Cause

ERR_PNPM_FILTER_NO_MATCH and silent full-workspace fallback both trace to the same thing: the selector did not resolve against the workspace graph. The usual causes are misconfigured boundaries in pnpm-workspace.yaml, a package whose package.json name does not match the pattern, a missing traversal operator (...) so dependencies are excluded, or — for change-aware filters — a shallow clone that gives the git diff nothing to compare against. Understanding the underlying resolution model in pnpm Workspace Filtering is what keeps these failures from recurring.

Root Cause ERRPNPMFILTERNOMATCH and silent full-workspace fallback both trace to the same thing: the selector did not resolve again Root Cause ERRPNPMFILTERNOMATCH and silent full-workspace fallback both trace to the same thing: the selector did not resolve against the workspace graph.
Root Cause — the core idea of this section at a glance.

Running a task across an entire workspace when only a few packages changed is wasteful, and the cause is simply that pnpm -r has no notion of what changed — it fans the task across every package unconditionally. Targeted builds exist to fix that by querying the workspace graph: a filter selects a subset by identity, by change since a git ref, or by graph relationship, so a task runs only where it is relevant. The mechanic is that pnpm has already built a dependency graph, and the filter is a query over it.

The direction of the graph traversal is what makes a filter correct or subtly wrong. Selecting a package plus its dependencies (...pkg) is right for building prerequisites first; selecting a package plus its dependents (pkg...) is right for testing what a change could break. Using the wrong direction produces a run that looks scoped but omits the packages that actually needed to run, which is the most common targeting mistake.

Diagnostic & Recovery Workflow

1. Validate workspace boundaries

Diagnostic & Recovery Workflow Confirm the manifest exists at the repository root and declares every package directory. Diagnostic & Recovery Workflow Confirm the manifest exists at the repository root and declares every package directory.
Diagnostic & Recovery Workflow — the core idea of this section at a glance.

Confirm the manifest exists at the repository root and declares every package directory.

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
pnpm list --recursive --depth=0

This lists every package pnpm recognizes. If a package you expected is absent, its directory is not covered by a glob, or its package.json has no name.

2. Test filter resolution with a dry run

Resolve the filter with pnpm list before you attach an expensive build to it. list is read-only, so it is safe to iterate on.

# Exact name
pnpm list --filter "my-package" --recursive

# Upstream: target + all dependencies
pnpm list --filter "...my-package" --recursive

# Downstream: target + all dependents
pnpm list --filter "my-package..." --recursive

3. Run the targeted build

Once the selector lists the packages you expect, attach the build. Use the upstream operator so dependencies compile first.

pnpm --filter "...my-package" run build

Read the terminal output and confirm only the intended package and its upstream dependencies executed.

Required Configuration Baseline

  • pnpm-workspace.yaml must sit at the repository root and cover every package directory.
  • package.json in every package needs a valid, unique name that matches the filter pattern you intend to use.
  • .npmrc should set auto-install-peers=true so peer dependencies resolve consistently across filtered scopes.
Required Configuration Baseline Required Configuration Baseline in production JavaScript package workflows. Required Configuration Baseline Required Configuration Baseline in production JavaScript package workflows.
Required Configuration Baseline — the core idea of this section at a glance.

CI/CD Safeguards

  • Change-aware filtering. Replace bare pnpm run build with pnpm --filter '...[origin/main]' run build so only modified packages and their dependencies build.
  • Full history. Set fetch-depth: 0 on actions/checkout — a shallow clone makes the git range resolve to nothing.
  • Fail on empty match. Add --fail-if-no-match (pnpm v8+) so a typo errors instead of silently building nothing.
  • Production-only installs. Use pnpm install --prod in deploy steps to keep devDependencies out of the resolution graph.
  • Pre-commit consistency. Run pnpm install --frozen-lockfile and pnpm list --recursive --depth=0 in a hook to catch a broken workspace before it reaches CI.
CI/CD Safeguards CI/CD Safeguards in production JavaScript package workflows. CI/CD Safeguards CI/CD Safeguards in production JavaScript package workflows.
CI/CD Safeguards — the core idea of this section at a glance.

Combining filters with a task runner's cache

A pnpm filter selects which packages run; a task runner's cache decides which of those actually need to. Layering the two is what makes a large workspace's targeted build fast rather than merely scoped. The filter removes packages the change cannot reach, and the runner replays any survivor whose inputs already have a stored result, on this machine or another through a remote cache. The result is that a one-line change runs its package and dependents, and even among those, only the ones whose inputs genuinely changed are recomputed.

Filter plus cache Filter selects, cache replays unchanged, cost tracks the change. filter affected changed + dependents cache replays unchanged survivors cost tracks change fast targeted build
A filter scopes the work; a task-runner cache makes the scoped work fast.

The two mechanisms are complementary and neither substitutes for the other. Filtering without caching still recomputes every selected package on every run; caching without filtering still checks every package in the repo even if most are irrelevant. Used together — turbo run build --filter='...[origin/main]', for instance, where Turborepo reads the same workspace graph pnpm does — the filter and the cache compose so the build's cost tracks the change, which is the property that keeps CI time bounded as the workspace grows.

Common targeting mistakes and how to catch them

The targeting mistakes that hurt most are the quiet ones, where a scoped command succeeds while doing less than intended. The most common is selecting a package without its dependents when the task is meant to validate the impact of a change — testing only the edited library, not the packages that import it, ships its consumers untested. The fix is to prefer the dependents-inclusive form (pkg... or ...[ref]) whenever a change could propagate, and the way to catch it is to preview the selected set and confirm the consumers you expect are present.

Targeting mistakes Whether a filter covers everything a change could affect. Does the run cover the change's impact? included covered omitted consumers untested shallow base unreachable
A too-narrow filter or an unreachable base runs successfully while skipping needed work.

The second recurring mistake is a shallow CI clone that makes the change base unreachable, which forces the change selector to fall back to selecting everything (slow) or, in some configurations, nothing (dangerous). Fetching adequate history prevents it. The third is an inaccurate dependency graph — an undeclared cross-package import the traversal cannot see — which makes the dependents form miss a real consumer. All three share a remedy: treat a filter as a claim about which packages matter for an operation, and verify that claim with a dry-run against the actual change before trusting a scoped CI run to have covered everything it needed to.

Path filters versus graph filters

pnpm's filter language offers two fundamentally different ways to select packages, and knowing when to reach for each removes guesswork. Graph filters — the ellipsis forms and the change selector — select by dependency relationship: a package plus its dependencies, a package plus its dependents, or everything changed since a ref plus dependents. Path filters, by contrast, select by location: --filter './packages/ui/**' matches every package under a directory regardless of the dependency graph. The two answer different questions — 'what is related to this package?' versus 'what lives in this part of the repo?'

Path vs graph filters Two ways pnpm selects packages. Filter Selects by Use for graph (...) dependency edges impact / prerequisites path (glob) location ownership slice
Graph filters select by dependency relationship; path filters select by location.

The practical rule is to use graph filters when correctness depends on dependency direction — testing a change's impact, building prerequisites — and path filters when you want a coarse, ownership-based slice, such as running a team's packages or a directory's linting. They also compose: combining a path filter with a change selector narrows to changed packages within a subtree, which is useful when different areas of a large monorepo have different pipelines. Reaching for the graph forms when a task's correctness depends on dependents, and the path forms when it depends only on location, keeps each targeted operation both correct and easy to reason about.

Filtering by dependency type and changed files

Beyond the graph and path selectors, pnpm's filter can narrow by whether a change touched a package's production or development files, which is useful for running expensive tasks only when they matter. The change selector can be combined with an awareness of what changed, so a deployment task runs only when a package's production sources changed, while a docs task runs when its documentation changed. This finer-grained selection avoids the waste of, say, redeploying a package because only its test files were edited.

Composable selection Combine location, change, and graph relationship. path filter location slice + change selector only what changed + dependents validate consumers
Composing path, change, and graph selectors runs each task against precisely its set.

The composition of selectors is what makes the filter language expressive enough for real pipelines. A path filter scoped to a team's directory, combined with the change selector, runs only that team's changed packages; the dependents form added to a change selector ensures a shared library's consumers are validated. Building a pipeline's selection from these composable pieces — location, change, and graph relationship — lets each task run against precisely the set it needs, which is both faster and more correct than a coarse whole-workspace run or a hand-maintained list of packages that drifts as the repo evolves.

Frequently Asked Questions

What is the difference between pnpm --filter my-package and pnpm --filter ...my-package? The ... prefix turns on upstream dependency-aware filtering, so pnpm builds the target plus every workspace dependency it needs — which prevents missing-module errors during compilation. Without the prefix, only the exact named package runs.

Why does pnpm --filter build packages I did not specify? You used a traversal operator. ...pkg pulls in the upstream dependency chain and pkg... pulls in the downstream dependent chain. Drop the operators and pass an exact name to restrict execution to a single package.

Why does --filter '...[origin/main]' build nothing in CI? The runner cloned shallowly, so the diff against origin/main has no history to compare. Set fetch-depth: 0 on the checkout step, and make sure the base ref is actually fetched.

Can I combine pnpm --filter with Turborepo or Nx? You can, but it is usually redundant — both provide their own task graph and caching. Reserve pnpm --filter for raw script execution or for bypassing the orchestrator on a single targeted install or audit.

What does the ... in a pnpm filter do?

It adds neighbours in the dependency graph. ...pkg adds the packages pkg depends on (build prerequisites first); pkg... adds the packages that depend on pkg (impact of a change). The ellipsis direction chooses which way you traverse the graph.

How do I make a targeted build both scoped and fast?

Combine the filter with a task runner's cache. The filter selects the affected packages; the cache replays any of them whose inputs are unchanged, locally or via a remote cache. Together the build's cost tracks the change rather than the repo size.

How do I verify a filter selected the right packages?

Preview it with pnpm --filter '<selector>' exec pwd, which prints the matched packages without running anything, and compare against the files you changed. A missing dependent means an undeclared cross-package import the graph could not follow.

When should I use a path filter instead of a graph filter?

Use a path filter (--filter './packages/ui/**') for a coarse, ownership- or location-based slice, and a graph filter (...pkg, pkg..., ...[ref]) when correctness depends on dependency direction — building prerequisites or testing a change's impact. They compose for changed packages within a subtree.

Can I combine a path filter with a change filter?

Yes — the selectors compose. A path filter scoped to a directory combined with the ...[ref] change selector runs only the changed packages within that subtree, which is useful when different areas of a large monorepo have different pipelines.

Does a filtered run respect topological build order?

Yes — pnpm sequences a filtered build topologically, so each package builds after the local packages it depends on, and independent packages run in parallel. The filter selects a correctly-orderable subgraph, not an arbitrary set.

What does --filter do when nothing matches?

pnpm reports that no packages matched and does nothing, which can silently pass in CI. Guard against a typo'd or over-narrow selector by previewing the matched set with a dry-run, so an empty selection is caught rather than mistaken for a successful run.

Related

pnpm Workspace Filtering