---
title: "GitHub Actions concurrency: cancel-in-progress explained"
description: "Add a concurrency group with cancel-in-progress so a new push cancels the obsolete run instead of piling up runners. Copyable GitHub Actions YAML."
url: https://starsling.dev/best-practices/github-actions/cancel-superseded-runs
canonicalUrl: https://starsling.dev/best-practices/github-actions/cancel-superseded-runs
---

# Cancel superseded runs with concurrency groups

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

Add a GitHub Actions `concurrency` group scoped to the ref and turn on `cancel-in-progress` for PR runs (it defaults to `false`), so a new push cancels the now-obsolete run instead of leaving both to occupy runners.

## 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

PR workflows declare a top-level `concurrency` group keyed on the workflow and ref, with `cancel-in-progress` set. Here is what the key actually does. It defaults to `false`: the run already in progress finishes, and the new run sits as `pending` until it does. Set it to `true` and the in-progress run in that group is cancelled immediately, so the new commit starts right away. That is what you want on a PR, where the superseded run is testing a commit no one is waiting on, and what you do not want on `main` or a release workflow, where every commit's run may deploy or publish and must complete. Scoping the value to the event (rather than a bare `true`) gives you both. The group itself is broad enough (per-workflow-per-ref) that it doesn't serialize unrelated jobs.

_One live run per ref; cancel superseded PR runs_

```yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  # Cancel superseded runs on PRs; let main / release runs finish.
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# On a workflow that ONLY runs on pull_request, the plain literal is fine:
#
#   concurrency:
#     group: ${{ github.workflow }}-${{ github.ref }}
#     cancel-in-progress: true
#
# The default is cancel-in-progress: false, which lets the in-progress run
# finish while the new run waits as pending. Never set the literal true on a
# workflow that also runs on push to main or on a release tag.
```

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

## Avoid this

Each new push leaves the old run going, so obsolete commits keep occupying runners.

_No concurrency: obsolete runs pile up_

```yaml
# No concurrency block. Every push to a PR starts another full run,
# and the superseded ones keep occupying runners until they finish.
```

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

## Seen in the wild

Real workflow files from public projects.

### moby/moby - .github/workflows/buildkit.yml

```yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  # Cancel stale PR runs without interrupting push, tag, scheduled, or
  # manually dispatched validation.
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
```

Key line: `cancel-in-progress: ${{ github.event_name == 'pull_request' }}`.

Cancels pull-request runs only. Pushes, tags, and scheduled validation are left to finish, and the comment says why.

Source: [moby/moby `.github/workflows/buildkit.yml` lines 3-7](https://github.com/moby/moby/blob/0657eaeb4ba0acf40013f75e5d49bb4afdce25d8/.github/workflows/buildkit.yml#L3-L7) at commit `0657eae`.

### vercel/next.js - .github/workflows/turbopack-benchmark.yml

```yaml
concurrency:
  # Limit concurrent runs to 1 per PR, but allow concurrent runs on canary branch
  group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}-{2}', github.workflow, github.ref_name, github.run_id) }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
```

Key line: `cancel-in-progress: ${{ github.event_name == 'pull_request' }}`.

The same conditional, with the concurrency group keyed per pull request so two branches never cancel each other.

Source: [vercel/next.js `.github/workflows/turbopack-benchmark.yml` lines 15-18](https://github.com/vercel/next.js/blob/a249dcbcee7267c08ec3ab0705b9a47b4c4097dd/.github/workflows/turbopack-benchmark.yml#L15-L18) at commit `a249dcb`.

### grafana/grafana - .github/workflows/pr-build-grafana.yml

```yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}
```

Key line: `cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}`.

The group is keyed per ref and cancellation switches on only for pull-request refs, so a rebuild supersedes its own branch and never a run on main.

Source: [grafana/grafana `.github/workflows/pr-build-grafana.yml` lines 7-9](https://github.com/grafana/grafana/blob/e011128c1e72cccdbb78391ad5f91aa9d724e31d/.github/workflows/pr-build-grafana.yml#L7-L9) at commit `e011128`.

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

## Why it matters

Without a concurrency group, rapid pushes to a PR leave several full runs racing, every obsolete one still burning a runner until it ends. Cancelling superseded runs reclaims that compute and frees capacity for runs that matter. Keep the group scoped to the ref, not a global constant, an over-narrow or over-broad group is its own problem (it can serialize or cancel jobs you didn't mean to).

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

## When to use

**Use it when:** PR- and branch-push workflows where developers commonly push several times in quick succession.

**Be careful when:** Do not use `cancel-in-progress: true` on the default branch or release workflows where every commit must complete (each may deploy or publish). Scope cancellation to PRs; let `main` runs finish.

<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 a top-level `concurrency:` block on `pull_request` and `push` workflows. Flag any that have none, or one with `cancel-in-progress: false`, so obsolete runs pile up on rapid pushes. Add a `concurrency` group keyed on `${{ github.workflow }}-${{ github.ref }}` with `cancel-in-progress` scoped to PRs, and make sure the default branch and release workflows still let every run finish (never set `cancel-in-progress: true` on `main`). Confirm the group includes `github.ref` so it does not serialize unrelated PRs into one lane. 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/concepts/workflows-and-actions/concurrency. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.

Prefer to check by hand:

- Check PR-triggered workflows for a top-level `concurrency:` block: `grep -rn 'concurrency' .github/workflows/`.
- Flag `pull_request`/`push` workflows with no concurrency group, or one with `cancel-in-progress: false`.
- Confirm the group key includes `github.ref` (per-ref), not a global constant that would serialize every PR into one lane.

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

## More best practices for GitHub Actions

- [Cut CI queue time in GitHub Actions](https://starsling.dev/best-practices/github-actions/cut-queue-time)
- [Filter workflows by path in GitHub Actions](https://starsling.dev/best-practices/github-actions/path-filter-workflows)
- [All CI best practices](https://starsling.dev/best-practices/github-actions)

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

## FAQ

### What does cancel-in-progress do, and what is its default?

`cancel-in-progress` decides what happens to the run already going in a concurrency group when a new one arrives. It defaults to `false`, so the in-progress run finishes and the new run waits as `pending`. With `cancel-in-progress: true`, GitHub cancels the in-progress run in that group immediately and starts the new one. Nothing else changes: the group key still decides which runs share a lane.

### Where does the concurrency block go, workflow or job level?

Put it at the top level of the workflow so it governs the whole run. Job-level concurrency is for finer control, but for cancelling superseded PR runs the workflow-level group keyed on `github.ref` is what you want.

### Is it safe to cancel in-progress runs on main?

No. On the default branch or a release workflow, every commit typically needs its run to complete, cancelling could skip a deploy or publish. Scope `cancel-in-progress: true` to PR refs and let main runs finish.

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

## Sources

1. [GitHub Actions - concurrency syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
2. [GitHub Actions - concurrency](https://docs.github.com/en/actions/concepts/workflows-and-actions/concurrency)

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

## Get started

Stop wasting CI minutes on commits no one is waiting on.
