---
title: "Path filters in GitHub Actions: skip irrelevant CI runs"
description: "Use paths and paths-ignore so expensive workflows skip docs-only or unrelated changes. Add path filters in GitHub Actions, with copyable YAML."
url: https://starsling.dev/best-practices/github-actions/path-filter-workflows
canonicalUrl: https://starsling.dev/best-practices/github-actions/path-filter-workflows
---

# Filter workflows by path in GitHub Actions

Best practice: Trigger Scope. Last updated: 2026-07-15

Add `paths` or `paths-ignore` to expensive workflows so a docs-only or unrelated change doesn't trigger the full test suite.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [Seen in the wild](#seen-in-the-wild)
- [Why it matters](#why-it-matters)
- [When to use](#when-to-use)
- [Verify on your repo](#verify)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

Expensive workflows (E2E, full integration suites) declare a `paths` filter listing the source directories that can actually affect them, or a `paths-ignore` list for docs and markdown. A README change no longer spins up the entire suite. Three syntax rules govern the filter: `paths` and `paths-ignore` are only evaluated on the `push`, `pull_request`, and `pull_request_target` events (on any other trigger they do nothing, and they are skipped for tag pushes); `*` matches any character except `/` while `**` matches across directories; and a `!` negation excludes what an earlier pattern matched, with the last matching pattern winning, which is why an exclusion like `!**/*.md` has to come last. That last rule is GitHub's own; `dorny/paths-filter` is a different matcher that ORs its patterns, so a `!` pattern there does not mean the same thing.

_Gate the job when the check is required (safe to paste as-is)_

```yaml
# A REQUIRED check must never be filtered at the workflow level: a workflow that
# does not run reports no status at all, and the PR blocks forever waiting for it.
# So leave the trigger unfiltered, compute what changed in a cheap job, and gate
# the expensive job with if:. A job skipped by a conditional reports Success,
# which satisfies branch protection.
#
# Not a required check? Then you do not need any of this. Filter the trigger
# itself and the workflow simply does not run:
#
#   on:
#     pull_request:
#       paths:
#         - "src/**"
#         - "package.json"
#         - "pnpm-lock.yaml"
#         - ".github/workflows/e2e.yml"   # re-run if the workflow itself changes
on:
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    # dorny reads the PR's file list from the REST API, so it needs
    # pull-requests: read. Declaring a permissions block at all sets every scope
    # you DON'T list to none, and new repos default to a restricted token that
    # lacks it anyway - either way, without this line the step fails with
    # "Resource not accessible by integration" and takes the required check down
    # with it. No checkout needed on pull_request events.
    permissions:
      contents: read
      pull-requests: read
    outputs:
      src: ${{ steps.filter.outputs.src }}
    steps:
      - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
        id: filter
        with:
          # dorny is not GitHub's matcher: it ORs its patterns, so a file that
          # matches ANY of them sets the output. Do not paste a "!" exclusion in
          # here expecting GitHub's last-match-wins rule - "!**/*.md" would match
          # every file that is not markdown, and the filter would fire on
          # everything. List what you DO care about.
          filters: |
            src:
              - "src/**"
              - "package.json"
              - "pnpm-lock.yaml"
  e2e:
    needs: changes
    if: ${{ needs.changes.outputs.src == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
      - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
        with:
          version: 10   # ubuntu-latest ships no pnpm; required unless package.json sets "packageManager"
      - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test:e2e
```

<a id="avoid-this"></a>

## Avoid this

A docs-only change spins up the full suite, spending wall-clock and runner minutes for nothing.

_Runs on every change, including docs_

```yaml
on:
  pull_request:   # a one-word README fix triggers the full E2E suite
```

<a id="seen-in-the-wild"></a>

## Seen in the wild

Real workflow files from public projects.

### oven-sh/bun - .github/workflows/packages-ci.yml

```yaml
on:
  push:
    branches:
      - main
    paths:
      - "packages/**"
      - .prettierrc
      - .prettierignore
      - tsconfig.json
      - oxlint.json
      - "!**/*.md"
```

Key lines: `paths:`, `- "!**/*.md"`.

Runs only when the packages tree or the shared config changes, and the trailing negation keeps a markdown-only edit from starting it.

Source: [oven-sh/bun `.github/workflows/packages-ci.yml` lines 3-13](https://github.com/oven-sh/bun/blob/6bb513507019905cb723e48d33c84667961e9ea6/.github/workflows/packages-ci.yml#L3-L13) at commit `6bb5135`.

### react/react - .github/workflows/runtime_build_and_test.yml

```yaml
  pull_request:
    paths-ignore:
      - compiler/**
```

Key lines: `paths-ignore:`, `- compiler/**`.

The runtime test suite never starts for a pull request that only touches the compiler.

Source: [react/react `.github/workflows/runtime_build_and_test.yml` lines 9-11](https://github.com/react/react/blob/c0c39a6b3907eaab35f43074949e2957a2a734c1/.github/workflows/runtime_build_and_test.yml#L9-L11) at commit `c0c39a6`.

### pola-rs/polars - .github/workflows/test-rust.yml

```yaml
  pull_request:
    paths:
      - crates/**
      - examples/**
      - Cargo.toml
      - .github/workflows/test-rust.yml
```

Key lines: `paths:`, `- .github/workflows/test-rust.yml`.

The Rust suite runs only when crate sources, examples, the manifest, or this workflow file itself change, so a Python-only or docs pull request skips it.

Source: [pola-rs/polars `.github/workflows/test-rust.yml` lines 4-9](https://github.com/pola-rs/polars/blob/dc91f6a456a2548a562c2d20d6c8f23e6ef47d67/.github/workflows/test-rust.yml#L4-L9) at commit `dc91f6a`.

<a id="why-it-matters"></a>

## Why it matters

Running an expensive suite on changes that can't affect it is pure waste, both wall-clock the developer waits on and runner-minutes you pay for. Path filters are a high-impact, low-risk trigger fix. The one thing to watch: if a required status check is path-filtered, a skipped run never reports its status, and GitHub has no branch-protection setting that treats a missing check as passed, so the PR waits forever. Either don't make the path-filtered workflow a required check, or gate the work with a job-level `if:` condition instead of a workflow-level `paths` filter: GitHub reports a job skipped by a conditional as `Success`, which satisfies the required check, whereas a skipped workflow reports nothing at all.

<a id="when-to-use"></a>

## When to use

**Use it when:** Workflows with several jobs and a clear set of source paths that gate them, especially E2E and integration suites.

**Be careful when:** Be careful path-filtering a required status check: a filtered-out workflow never reports, and GitHub can't treat a missing check as satisfied, so the PR stays blocked. Prefer path-filtering non-required expensive workflows. If the check must stay required, gate the work with a job-level `if:` condition instead: a job skipped by a conditional reports `Success` and satisfies the check, unlike a workflow skipped by `paths`. To compute the condition that `if:` tests, run `dorny/paths-filter` in a cheap upstream job (pinned to its commit SHA) and read its per-filter output, as in the YAML above.

<a id="verify"></a>

## Verify on your repo

Hand this prompt to your coding agent to audit and fix this practice in your own repo:

Inspect this repo's .github/workflows for expensive workflows (E2E, integration suites) that trigger on every change with no path scoping. For each, look at the `on:` block for `paths:` or `paths-ignore:` and flag multi-job workflows that have neither. Add a `paths` allowlist of the source directories that can actually affect the workflow (include the workflow file itself) so a docs-only change does not spin up the full suite. Important: do not path-filter a required status check, since a skipped workflow never reports and blocks the PR forever, so instead gate the expensive work with a job-level `if:` condition (a job skipped by a conditional reports Success). Show me the diff and open a PR rather than applying it blindly.

Ground these changes in the upstream docs before you edit: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax, https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.

Prefer to check by hand:

- Look at the `on:` block of each expensive workflow for `paths:` or `paths-ignore:`.
- Flag workflows with several jobs and no path filtering at all.
- For any required check, don't path-filter the whole workflow: either drop it from the required list, or gate the expensive work with a job-level `if:` (a job skipped by a conditional reports `Success`), so PRs aren't blocked waiting on a check that never runs.

<a id="more-best-practices"></a>

## More best practices for GitHub Actions

- [Cancel superseded runs with concurrency groups](https://starsling.dev/best-practices/github-actions/cancel-superseded-runs)
- [Build and test only what changed in GitHub Actions](https://starsling.dev/best-practices/github-actions/build-only-affected)
- [All CI best practices](https://starsling.dev/best-practices/github-actions)

<a id="faq"></a>

## FAQ

### Should I use paths or paths-ignore?

`paths` is an allowlist (run only when these change) and is safest for a suite with a well-defined source set. `paths-ignore` is a denylist (skip when only these change) and fits when you mainly want to exclude docs and markdown. Don't set both on the same event.

### Will a path filter break a required status check?

It can. When a workflow is skipped by a `paths` filter, its check context never reports, and branch protection waits for it forever, GitHub has no setting that counts a missing check as passed. The safe fix: don't require a path-filtered workflow, or gate the expensive job with a job-level `if:` condition instead. GitHub reports a job skipped by a conditional as `Success` (which satisfies the required check), whereas a skipped workflow reports nothing.

### Why is my paths filter being ignored?

GitHub only evaluates `paths` and `paths-ignore` on the `push`, `pull_request`, and `pull_request_target` events, and it skips them for tag pushes. On a `schedule`, `workflow_dispatch`, or `workflow_call` trigger the filter does nothing and the workflow runs anyway. The other common cause is the glob: `*` stops at a `/`, so `src/*` matches only the files directly under `src`, while `src/**` matches the whole tree beneath it.

<a id="sources"></a>

## Sources

1. [GitHub Actions - paths / paths-ignore filters](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)
2. [GitHub Actions - filter pattern cheat sheet (workflow syntax)](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
3. [dorny/paths-filter](https://github.com/dorny/paths-filter)
4. [GitHub - troubleshooting required status checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks)

<a id="get-started"></a>

## Get started

Don't run the full suite on a docs change.
