skip to content

In GitHub Actions, what does a concurrency group with cancel-in-progress do?

level: middleimportance: must knowfreq 66%

answer

  1. One lane per key
  2. The newest push wins, the middle ones vanish
  3. Only one run may wait
  4. The key expression is the whole design
  5. Fine for pull requests, dangerous for deploys

basics

~20 s

A concurrency group allows only one run of that group at a time. With cancel-in-progress true, a new run cancels the one already running; with it false or absent, the new run waits, and only the newest waiting run survives.

solid answer

~40 s

`concurrency` takes a `group` string — usually built from expressions such as `${{ github.workflow }}-${{ github.ref }}` — and serialises everything sharing that string. Set `cancel-in-progress: true` and a newly triggered run cancels the in-flight one; leave it out and the new run sits `pending` until the current one finishes. Crucially only **one** run may be pending per group: if a third arrives, the queued one is cancelled and replaced, so you always get the oldest running plus the newest queued. Group names are case-insensitive. You can declare `concurrency` at workflow level or on an individual job, and `cancel-in-progress` accepts an expression, which is how teams cancel superseded pull-request builds while never interrupting a deploy: `cancel-in-progress: ${{ github.event_name == 'pull_request' }}`.

code

yaml · 18 lines
yaml
name: CI

# One lane per workflow + ref; only pull-request runs are disposable.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./gradlew build

go deeper

for a junior

Recall that concurrency limits a workflow to one run per group key and that cancel-in-progress: true cancels the older run. Know the common ${{ github.workflow }}-${{ github.ref }} group.

for a middle

Explain the queueing mechanics precisely: at most one pending run per group, newer arrivals cancelling the queued one, case-insensitive group names, and the difference between workflow-level and job-level declarations.

for a senior

Demonstrate the production judgment — cancellable pull-request builds versus non-cancellable deploys, an expression-valued cancel-in-progress, and cleanup steps guarded by if: cancelled() so a cancelled run leaves nothing locked.

for a principal

Own the cost and safety tradeoff across the org: how group keys are standardised, where serialisation protects shared environments, and when a queued deploy lane becomes a delivery bottleneck that needs a different design.

## The problem it solves Push three commits to a pull request in five minutes and, by default, GitHub Actions starts three full runs. The first two are already obsolete — nobody will look at their results — but they hold runner capacity and burn minutes. On the other side of the pipeline, two deploy runs racing to the same environment is worse than wasteful: it is a correctness problem. `concurrency` is the one key that addresses both. ## Syntax ``` concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ``` `group` is any string; the expressions are just how you make it meaningful. Two runs are in the same group if their evaluated group strings are equal, compared **case-insensitively**. The shorthand `concurrency: my-group` sets the group with `cancel-in-progress` defaulting to false. ## The three-run rule The queueing behaviour is small but exact, and it is what interviewers probe. - **`cancel-in-progress: true`.** A new run cancels any in-progress run in the same group and starts. Cancellation propagates to the running jobs, which receive a cancellation signal; `if: always()` steps still get their chance to run, `if: cancelled()` steps run specifically on this path. - **`cancel-in-progress: false`** (the default). The new run enters `pending` and waits for the in-progress run to complete. - **A third arrival, either way.** Only one run may be pending per group. When a newer run arrives, the currently pending run is **cancelled** and the newcomer takes its place. So the steady state is at most one running plus one queued — the oldest in flight and the newest waiting, with everything in between discarded. ## Choosing the group key The group expression *is* the design decision. - `${{ github.workflow }}-${{ github.ref }}` — per workflow, per branch or PR. The default choice for build/test workflows: superseded commits on one branch cancel each other, unrelated branches never interfere. - `${{ github.workflow }}` alone — every branch shares one group. Almost always a bug: a push to a feature branch cancels the run for `main`. - `deploy-production` (a constant) — one global lane, typically with `cancel-in-progress: false`, so deploys queue rather than race. - `${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}` — a common variant keeping pull-request runs distinct from branch runs of the same ref. Note that `github.ref` differs by event: on a `pull_request` event it is `refs/pull/<n>/merge`, on a push it is `refs/heads/<branch>`. That is usually what you want — the PR run and the branch run stay in separate groups — but it explains why two runs you expected to collide sometimes do not. ## Workflow level versus job level Declared at the top level, `concurrency` governs the whole run. Declared inside a job, it governs that job only, and other jobs in the same run proceed independently. The idiomatic combination is an aggressive workflow-level group for the fast feedback path plus a conservative, non-cancelling job-level group on the deploy job: ``` concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: deploy: concurrency: group: deploy-production cancel-in-progress: false ``` `cancel-in-progress` accepting an expression is what makes that first block safe: pull-request runs are disposable and get cancelled, while runs on the default branch are never interrupted mid-deploy. ## Failure modes to name **Cancelling a deploy halfway.** Terraform mid-apply, a database migration mid-transaction, a partially-rolled-out service. Deploy jobs want a constant group and `cancel-in-progress: false`; if the work is genuinely not resumable, queueing is the only safe answer. **Too-broad a group.** Constant groups on a build workflow serialise the whole repository and make CI feel mysteriously slow — runs sit `pending` with no explanation on the run page beyond "waiting". **Expecting deduplication.** Concurrency does not merge runs or skip identical work; it cancels and queues. It also does not span repositories — groups are scoped to the repository. **Confusing it with a matrix limit.** `max-parallel` bounds parallel legs inside one job's matrix; `concurrency` bounds runs or jobs across the repository. Different keys, different scope. ## Why interviewers like it It is a two-line change with visible cost impact — often the single biggest reduction in wasted runner minutes available in a repository — and it has a genuine sharp edge, since the same two lines applied to a deploy workflow can interrupt production work. A candidate who can state the queueing rule *and* the deploy caveat has clearly run this in anger.

  • Three pushes land on a branch while a run is already in progress and cancel-in-progress is false. What is the end state?
    The first extra run goes `pending`. When the next arrives it **cancels** the pending run and takes its place, and the third does the same — only one run may be queued per group. So the original run finishes, then the newest push runs; the intermediate ones are cancelled without ever starting.
  • Why is concurrency: group: ${{ github.workflow }} usually the wrong key?
    It omits the ref, so every branch and pull request shares one lane. With `cancel-in-progress: true` a push to any feature branch cancels the run for the default branch; with it false, unrelated branches serialise behind each other and CI appears mysteriously slow. Include `${{ github.ref }}` so each branch or PR gets its own group.
  • How do you cancel superseded pull-request builds without ever interrupting a production deploy?
    Make `cancel-in-progress` an expression: `${{ github.event_name == 'pull_request' }}`, so only PR runs are cancellable. Then give the deploy job its own job-level `concurrency` with a constant group such as `deploy-production` and `cancel-in-progress: false`, so deploys queue behind one another instead of racing or being killed mid-apply.
  • What does a cancelled run do to the steps that were executing?
    Jobs receive a cancellation signal and stop. Steps with `if: always()` still get a chance to run, and `if: cancelled()` targets this path specifically — that is where you put artifact upload, teardown of external resources, or an unlock. Work that cannot be interrupted safely, such as a migration mid-transaction, should not be in a cancellable group at all.

saying these in an interview costs you the question

  • Thinks queued runs all eventually execute in order
  • Uses one global group for every workflow and branch
  • Enables cancel-in-progress on the deploy workflow
  • Confuses concurrency with matrix max-parallel
  • Believes concurrency deduplicates identical work

context