Skip to content
Français
CI/CD & Automatisation medium

GitHub Actions concurrency: avoiding parallel runs

30 min de lecture

Read this page in French

By default, GitHub Actions runs every trigger independently. If you push 5 commits quickly, you get 5 workflows in parallel. That is a waste of resources and, worse, a source of deployment conflicts. The concurrency block settles both.

What you will learn

  • Cancel the obsolete runs automatically with cancel-in-progress
  • Define a concurrency group per branch, per PR or per environment
  • Serialise the deployments to avoid two simultaneous ones
  • Protect the main branch from untimely cancellations
  • Avoid the traps: a group that is too wide, too specific, or dangerous

The problem

With no guardrail, every push starts its own workflow. On an active branch, the first runs are already obsolete before they even finish: only the last commit matters.

Commit 1 -> Workflow 1 (running)
Commit 2 -> Workflow 2 (running) <- Useless, commit 2 will be superseded
Commit 3 -> Workflow 3 (running) <- Useless too
Commit 4 -> Workflow 4 (running) <- Useless too
Commit 5 -> Workflow 5 (running) <- Only this one really counts

The fix: cancel the obsolete runs automatically.

The basic syntax

The concurrency block is declared at workflow level (or at job level). Two properties are enough: a group and the decision whether to cancel or not.

name: CI
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
steps: [...]

The properties

group accepts any string, expressions ${{ }} included, evaluated when the run starts: that is what gives you one group per branch or per PR. cancel-in-progress is the only behaviour switch, and it only concerns the runs of the same group that are already in flight.

PropertyDescription
groupThe identifier of the concurrency group
cancel-in-progressCancel the running runs of the same group

The concurrency groups

Everything plays out in how the group is composed. Two runs sharing an identical group compete; otherwise they ignore each other. Here are the four most useful splits.

Per branch

That is the split to remember as a default. github.ref holds the complete reference (refs/heads/main), so two branches never end up in the same group, and github.workflow prevents one workflow from cancelling another.

# Cancels the previous runs on the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

A push to main cancels the previous runs on main, but not those on develop.

Per PR

This group relies on the PR number rather than on the branch. It is useful when the workflow also reacts to PR events (reopening, target change) where github.ref does not designate the same thing from one event to the next. On a run triggered outside a PR, the expression is empty and all those runs fall into a common group.

# Cancels the previous runs on the same PR
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true

Per workflow

A constant group, with no expression, queues every run of the workflow whatever the branch. That is the configuration of a deployment: a single run at a time, and the following ones wait instead of being cancelled.

# A single run of this workflow at a time (global)
concurrency:
group: deploy-production
cancel-in-progress: false # Wait instead of cancelling

Per environment

Here the group depends on a run parameter: two deployments towards different environments proceed in parallel, two deployments towards the same environment are serialised. The || 'staging' expression provides a fallback value when the workflow is not triggered manually, without which the group would be empty.

# A single deployment per environment
concurrency:
group: deploy-${{ github.event.inputs.environment || 'staging' }}
cancel-in-progress: false

The common patterns

The four configurations below cover almost every need. They differ on a single question: is the work in flight disposable because a more recent commit replaces it, or irreplaceable because it modifies a system?

A classic CI

Cancelling the obsolete runs to save resources: a compilation and its tests are replayable, nothing is lost by interrupting them. On an active repository, that single block removes most of the wasted minutes.

name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm ci && npm test

A sequential deployment

Do not cancel, but wait for the previous deployment to be finished: a deployment script cut off mid-flight leaves the infrastructure in an intermediate state, with half the instances updated. Queuing guarantees that a single deployment writes at a time, and that the latest commit goes last.

name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false # Wait, do not cancel
permissions: {}
jobs:
deploy:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: ./deploy.sh

Telling CI and deployment apart

A workflow that tests then deploys needs both behaviours at once. The solution is to declare them at different levels: the workflow block cancels the obsolete runs, the block of the deploy job enforces its own queue. The group of the job stays independent from the one of the run holding it.

name: CI and Deploy
on:
push:
branches: [main]
# Global concurrency for this workflow
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# No rights by default
permissions: {}
jobs:
test:
runs-on: ubuntu-24.04
steps:
- run: npm test
deploy:
needs: test
runs-on: ubuntu-24.04
# Specific concurrency for the deployment
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- run: ./deploy.sh

Protecting the runs on main

Do not cancel the runs on the main branch: on main, every commit deserves its own result, if only to know which one broke the build. cancel-in-progress accepts an expression, which lets you cancel on the working branches while letting the runs of main go all the way.

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# Cancel only on the PRs
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

The behaviour in detail

The value of cancel-in-progress radically changes what happens to the runs already started. Let us compare the two modes.

With cancel-in-progress: true

The arrival of a new run interrupts every run of the group, including those that were almost done. The cancelled jobs appear with the cancelled status and publish neither artifacts nor test results.

Workflow 1 (running) -> CANCELLED
Workflow 2 (running) -> CANCELLED
Workflow 3 (new) -> RUNS

With cancel-in-progress: false

The run in flight goes all the way whatever happens, which is exactly what you expect from a deployment or from publishing a package. The following runs stay pending without tying up a runner as long as the group is not released.

Workflow 1 (running) -> FINISHES
Workflow 2 (pending) -> WAITS
Workflow 3 (pending) -> WAITS

The pending workflows then run in order.

Concurrency at job level

You can also define the concurrency at the level of one specific job: that is the right setting when a single job of the workflow touches a shared system. The other jobs stay free to run in parallel, only the job concerned is queued. A job blocked by its group waits without consuming a runner, but the whole run is still marked as in progress until it has started.

jobs:
test:
runs-on: ubuntu-24.04
# No concurrency restriction for the tests
steps: [...]
deploy:
needs: test
runs-on: ubuntu-24.04
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
steps: [...]

Debugging

The effects of concurrency are seen after the fact: a run disappears, another stays pending with no apparent explanation. Two reflexes are enough to remove the doubt: reading the cancellation reason GitHub displays, and having the workflow print the values making up its group.

Seeing the cancelled runs

In the Actions tab, the cancelled runs show the "Cancelled" status with the message "This run was cancelled because a newer run was started".

Concurrency logs

The concurrency block appears nowhere in the logs: to know which group a run actually computed, you have to print the context values making it up yourself. It is the most direct way to spot an empty expression, the classic cause of unexpected cancellations.

- name: Debug the concurrency
env:
# Context values go through env:, never in plain text inside run:
WORKFLOW: ${{ github.workflow }}
REF: ${{ github.ref }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
echo "Workflow: $WORKFLOW"
echo "Ref: $REF"
echo "Run ID: $RUN_ID"
echo "Run attempt: $RUN_ATTEMPT"

The common mistakes

Three group settings turn against you. Here is how to recognise and fix them.

A group that is too wide

A constant group puts every workflow of the repository in competition: triggering a CI on one branch cancels the CI of another branch, and the developers see their runs disappear for no visible reason.

# ❌ Every workflow is in the same group!
concurrency:
group: ci
cancel-in-progress: true
# ✅ A group per workflow AND per branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

A group that is too specific

github.run_id is unique per run: every run therefore ends up alone in its group and never competes with another. The block is there, it simply does nothing, which is harder to spot than a missing configuration.

# ❌ Every run gets its own group (useless)
concurrency:
group: ${{ github.run_id }}
# ✅ A group per branch
concurrency:
group: ${{ github.ref }}

Cancelling the deployments

That is the most expensive mistake: applying the setting meant for CI to a deployment. A terraform apply or a kubectl apply interrupted mid-run leaves the state partially applied, and nothing announces it.

# ❌ Dangerous: can cancel a deployment in flight
concurrency:
group: deploy
cancel-in-progress: true
# ✅ Wait for the previous deployment
concurrency:
group: deploy
cancel-in-progress: false

Key points

  • concurrency groups the runs and cancels or queues the ones of the same group.
  • The canonical group is ${{ github.workflow }}-${{ github.ref }}: one group per workflow and per branch.
  • cancel-in-progress: true for the CI (saving minutes), false for the deployments (never cut a deployment in flight).
  • A group that is too wide cancels unrelated runs; a group that is too specific (github.run_id) never cancels anything.
  • Protect main by making the cancellation conditional: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}.

For the complete reference, read the official documentation on concurrency.

Next steps

  • GitHub-hosted vs self-hosted runners: the number of available runners sets the real queue, the one concurrency comes to discipline.
  • Runner maintenance: watching the queue and sizing the fleet when the concurrency groups are no longer enough.
  • GitHub CLI (gh): listing, following and cancelling runs from the terminal to check the behaviour of your groups.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn