GitHub Actions path filters skip jobs, and can block the merge
GitHub Actions path filters skip jobs whose files did not change, which is the cheapest speed-up in the product and the one most likely to wedge a pull request. A workflow that never starts also never reports its check, so a branch rule requiring that check leaves the merge button grey with nothing failing anywhere.

The idea is obvious enough that most repositories adopt it in an afternoon: if the pull request only touched the docs directory, do not spend twelve minutes compiling. Four lines of YAML and the bill drops.
The parts that are not obvious are what the filter compares, three documented cases where it does the opposite of what you configured, and what a skipped workflow does to a required status check. This page is those three things, in that order.
What the filter compares, and when it compares nothing
A path filter is evaluated against a list of changed files, not against your working tree. GitHub documents how that list is produced: two-dot diffs for pushes and three-dot diffs for pull requests. On a pull request the comparison is between the most recent version of the topic branch and the commit where the topic branch was last synced with the base branch. On a push to an existing branch it is the head and base SHAs compared directly, and on a push to a new branch it is a two-dot diff against the parent of the ancestor of the deepest commit pushed.
Two consequences fall straight out of that. First, the documentation states plainly that if there are no files changed, the workflow will not run, which is why an empty commit is a poor way to re-trigger a filtered workflow. Second, path filters are not evaluated for pushes of tags at all, so a release workflow that fires on a tag runs regardless of what the tag points at. Neither behavior is a bug and neither is guessable from the YAML.
Use paths or paths-ignore, never both
GitHub documents this as a hard rule rather than a preference: you cannot use paths and paths-ignore to filter the same event in a single workflow. If you want both directions, you use paths and prefix the exclusions with !. There is a second rule attached to that which is easy to trip over: if you define a path with the ! character, you must also define at least one path without it.
Order then decides the outcome, and the documented rule runs in both directions. A matching negative pattern after a positive match excludes the path; a matching positive pattern after a negative match includes it again. So the list is read top to bottom and the last pattern that matches wins, which means moving one line changes which builds run.
on:
push:
paths:
- 'sub-project/**'
- '!sub-project/docs/**'
pull_request:
paths-ignore:
- '**.md'Three limits that reverse the answer
Under three documented conditions Actions stops honoring the filter, and they do not all fail in the same direction. Two of them make the workflow always run, which costs money and confuses nobody. The third makes it not run, which is the one that silently ships an untested change.
The asymmetry is worth holding on to. A very large push runs everything, because the service will not diff a thousand commits to save you a job. A very large diff can run nothing, because the file list is truncated and your filter is matched against the truncated list. If you have ever merged a giant vendored-dependency update and watched CI stay quiet, this is the row that explains it.
| Condition | What the filter does | What it costs you |
|---|---|---|
| A push contains more than 1,000 commits | The workflow always runs | Minutes on work the filter was meant to skip |
| Generating the diff times out | The workflow always runs | Minutes, and no signal that it happened |
| The diff has more than 3,000 files and the matched files are not in the first 3,000 | The workflow does not run | A change merges with the job that covers it never having run |
| The push is a tag push | Filters are not evaluated | Nothing, unless you assumed otherwise |
The required check that never arrives
This is the failure that sends people looking for a broken runner. A workflow skipped by path filtering does not report a neutral result or a pass. It reports nothing, because it never ran, and a branch rule that requires a check from that workflow waits for a report that is not coming.
GitHub documents the outcome and the merge box states it in as many words. The fix in the documentation is not a trick; it is the instruction to stop doing it: avoid requiring workflows that can be skipped. The related rulesets guidance says the same thing, that you should not use path or branch filtering to skip workflow runs if the workflow is required to pass before merging.
Waiting for status to be reportedFilter inside the job instead
That distinction is the whole design. Let the workflow start on every pull request so the check always reports, and decide inside it whether to do the expensive part. The job that gates the rest is cheap: a checkout and a diff, seconds rather than minutes, and it leaves a real check behind.
The common way to do this is a change-detection action that returns one boolean per named filter, which downstream jobs read in their needs and if. The action below is dorny/paths-filter, whose action.yml at v4.0.3 declares filters as its one required input and a changes output described as a JSON array with names of all filters matching any of changed files. Everything else, including how it finds the base to compare against, has a default.
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
api:
- 'services/api/**'
web:
- 'apps/web/**'
api-tests:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: latchkey-small
steps:
- uses: actions/checkout@v5
- run: make test-apiWhich one to reach for
Pick by what you are protecting rather than by which is tidier. If nothing requires the check, the workflow-level filter is strictly better: it costs zero seconds, because no runner is ever allocated. The moment a branch rule names that check, the same filter becomes a merge blocker and the decision has to move inside the workflow.
There is a third case people forget. A monorepo where every pull request touches something shared gets very little from any of this, because the filter matches nearly every time. Measure how often your filter would actually have skipped before you add the machinery to maintain it.
| Approach | Runner cost when nothing changed | What the required check does | Where it breaks |
|---|---|---|---|
paths / paths-ignore on the on: block | None; no job is queued | Never reports, blocks the merge | Any workflow named by a branch rule |
A job-level if: on a static condition | A queued job that skips immediately | Reports "Success" | The condition cannot see which files changed |
A change-detection job plus needs and if: | One short job, a checkout and a diff | Reports "Success" on every pull request | Adds a job to maintain, and a fetch depth to tune |
Why no recorded run backs this page
Every claim above is a documented rule or a string read out of a source file, and that is deliberate, because the behavior this page describes happens before any runner exists. The filter is evaluated by the service when it decides whether to create a workflow run at all. There is no job, no machine and no log to capture, so a reproduction harness pointed at it would record an empty run list and prove nothing.
The one observable artifact is a check state in a merge box, which is a screenshot rather than a log line, and the 3,000-file limit in particular needs a pull request that large before it shows itself. So this page cites GitHub's reference for the rules and the action's own action.yml for the interface, and leaves the numbers in the table as what GitHub publishes rather than as something we measured.
Frequently asked questions
Why is my pull request stuck waiting for a status that never reports?
Can I use paths and paths-ignore in the same workflow?
paths and paths-ignore to filter the same event in a single workflow. To both include and exclude, use paths and prefix the exclusions with !, and remember that you must also define at least one path without the ! character. Different events in the same file may each use whichever keyword suits them.Does a path filter always skip the workflow when nothing relevant changed?
What is the difference between a skipped workflow and a skipped job?
if: rather than in the on: block whenever a branch rule names the check.Related guides
References
- GitHub Docs: workflow syntax, paths and paths-ignore, the diff rules and the three limits (verified 2026-09-21)
- GitHub Docs: troubleshooting required status checks, including skipped but required checks (verified 2026-09-21)
- GitHub Docs: skipping workflow runs, and what a skipped run does to a required check (verified 2026-09-21)
- dorny/paths-filter action.yml: inputs, defaults and the changes output (verified 2026-09-21)
- GitHub Actions documentation