Fixing pnpm --filter Matching No Projects
pnpm --filter <selector> run build finishing instantly with "No projects matched the filters" is one of the most common surprises in pnpm workspaces. Worse, in older versions and some configurations the command exits successfully, so a CI step that was supposed to build or test something silently does nothing. Because the output is a single short line, it is easy to miss in a long CI log, and a green pipeline gives no hint that the step was a no-op. The cause is always a mismatch between the selector and how pnpm identifies packages — by name field, by directory, or by git changes — plus a few quoting and path rules. This guide walks through each selector type, shows how to see what pnpm matched, and makes empty selections fail loudly.
Exact symptoms and error messages
The message pnpm prints when a selection is empty:
$ pnpm --filter ui run build
No projects matched the filters in "/repo"
With --fail-if-no-match (or in CI configurations that set it), the command fails instead:
ERR_PNPM_NO_MATCHING_PACKAGES No packages matched the filters in "/repo"
A related failure appears when the selection is non-empty but none of the selected packages has the script:
$ pnpm --filter "./apps/**" run typecheck
None of the selected packages has a "typecheck" script
Root cause analysis
pnpm evaluates each --filter value against the workspace packages it discovered from pnpm-workspace.yaml. A selector is interpreted as a package name pattern unless it starts with . or { (a directory) or contains [...] (a git ref). The full selector language is covered in pnpm Workspace Filtering.
The usual mistakes:
- Using the folder name as a package name. The folder is
packages/ui, the package is@acme/ui.--filter uimatches nothing;--filter @acme/ui,--filter "*ui"or--filter ./packages/uiall work. - Globs without quotes. The shell expands
--filter @acme/*or--filter ./apps/*before pnpm sees it. If the glob matches files in the current directory, pnpm receives a different argument entirely. Always quote selectors that contain*. - Paths relative to the wrong directory. Path selectors are resolved relative to the current working directory, not the workspace root.
--filter ./packages/uifrom insideapps/webpoints atapps/web/packages/ui. - A git ref that does not exist locally.
[origin/main]in a shallow clone has no history to diff against, so nothing is selected — see Filtering Packages Changed Since a Git Ref. - The package is not in the workspace. If the folder is not matched by
pnpm-workspace.yamlglobs, no selector can find it — the same discovery problem as in Fixing ERR_PNPM_WORKSPACE_PKG_NOT_FOUND.
Resolution: see what pnpm sees
Before fixing a selector, list what exists and what a selector matches:
# Every package pnpm knows about, with names and paths
pnpm list -r --depth -1
# What a specific selector selects (no script is run)
pnpm --filter "@acme/*" list --depth -1
pnpm --filter "./apps/**" list --depth -1
pnpm --filter "...[origin/main]" list --depth -1
Then correct the selector:
# By exact name
pnpm --filter @acme/ui run build
# By name glob (quoted)
pnpm --filter "@acme/ui*" run build
# By directory (from the workspace root)
pnpm --filter ./packages/ui run build
# By directory glob, all apps
pnpm --filter "./apps/**" run build
# Name plus its dependencies
pnpm --filter "@acme/web..." run build
For scripts that may run from subdirectories, use the brace syntax, which is always relative to the workspace root:
pnpm --filter "{packages/ui}" run build
Combining multiple filters
Several --filter flags in one command form a union: a package is selected if any positive filter selects it and no negative filter excludes it. That makes complex selections readable, but it also means one wrong filter among several can go unnoticed because the others still select something.
# All apps plus the ui package and its dependents, excluding docs
pnpm --filter "{apps/*}" --filter "...@acme/ui" --filter "!@acme/docs" run build
Negative filters start with ! and must be quoted in most shells, because ! triggers history expansion in interactive bash. An unquoted --filter !@acme/docs can fail with event not found before pnpm even runs, or be rewritten into an unrelated command from history.
When a combined selection looks wrong, test each filter on its own with list --depth -1, then add them back one at a time. The union behaviour also explains a surprise with --filter and -r: in pnpm, --filter implies a recursive run over the selection, so adding -r is unnecessary, and running pnpm -r run build without any filter selects every package including the root if it has the script.
Selectors inside package scripts
Filters written inside package.json scripts run with the working directory set to the package that owns the script. A root script "build:apps": "pnpm --filter ./apps/** run build" works when invoked from the root, but the same line copied into a package's scripts resolves ./apps/** relative to that package and matches nothing. The braced form {apps/**} is relative to the workspace root regardless of where it runs, which makes it the safer choice for any selector that lives in a script or a shared CI template.
Making empty selections fail
A filter that matches nothing should almost never succeed silently in CI. Turn on the failure explicitly:
pnpm --filter "@acme/web..." --fail-if-no-match run build
Or set it for the project:
# .npmrc
fail-if-no-match=true
For changed-since selectors, an empty selection can be legitimate — a pull request that touches only documentation. In that case, handle it deliberately: log "no affected packages" and exit successfully, rather than relying on the default behaviour. A small wrapper script makes the intent explicit:
selected=$(pnpm --filter "...[origin/main]" list --depth -1 --json | jq length)
if [ "$selected" -eq 0 ]; then
echo "No affected packages; skipping tests."
exit 0
fi
pnpm --filter "...[origin/main]" run test
Missing scripts in selected packages
When the selection is right but some packages lack the script, pnpm skips them. If none has it, pnpm reports it. Use --if-present only when missing scripts are expected, and be aware that it also hides typos: pnpm -r --if-present run tyepcheck succeeds after running nothing. A CI check that lists which packages actually ran the script, or task-runner configuration that declares tasks explicitly, catches that.
Worked example: a deploy step that deployed nothing
A pipeline's deploy job runs pnpm --filter api run deploy. For weeks it "succeeds" in a few seconds — and deploys nothing, because the package is named @acme/api. Nobody notices until a hotfix does not appear in production. The fix is a one-word change to @acme/api, plus fail-if-no-match=true in the project .npmrc so any future typo fails the job, plus a line in the deploy script that prints the selected packages before running. The team also switches other CI steps to braced path selectors, which do not depend on package names at all.
Prevention and CI/CD guardrails
- Set
fail-if-no-match=truein the project configuration. - Quote every selector with wildcards and use braced paths in scripts.
- Print the selection at the start of CI steps with
pnpm --filter ... list --depth -1. - Keep folder and package names aligned so the folder-name mistake is less likely.
Frequently Asked Questions
Can I filter by folder name without the scope?
Use a name glob such as "*ui" or a path selector such as {packages/ui}. A bare ui only matches a package whose name is exactly ui.
Why does the same filter work locally but not in CI?
Different working directories, a shallow clone for git selectors, or an unquoted glob that the CI shell expands differently. Compare pnpm --filter ... list output in both environments.
Does --filter work with pnpm exec and pnpm dlx?
It works with exec (running a command in each selected package). dlx runs a one-off package and does not use workspace filtering.
Is the root package ever selected by a filter?
Only if a selector matches it — by its name, or with a path selector for the root folder. Recursive runs without filters include the root only when include-workspace-root is enabled or you pass -w. If a root script unexpectedly runs (or does not), check that setting.
Why does a name glob match more packages than expected?
Globs match anywhere in the name pattern you give, and scoped names include the scope. "*ui*" matches @acme/ui, @acme/ui-icons and @acme/build-utils. Prefer exact names or directory selectors in automation.
Related
- pnpm Workspace Filtering documents the full selector language.
- Using pnpm --filter for Targeted Builds shows everyday filter patterns.
- Filtering Packages Changed Since a Git Ref covers git-based selectors in depth.
- Fixing 'npm run: script not found' in a Workspace handles the related missing-script problem.