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
mainbranch 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 supersededCommit 3 -> Workflow 3 (running) <- Useless tooCommit 4 -> Workflow 4 (running) <- Useless tooCommit 5 -> Workflow 5 (running) <- Only this one really countsThe 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.
| Property | Description |
|---|---|
group | The identifier of the concurrency group |
cancel-in-progress | Cancel 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 branchconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: trueA 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 PRconcurrency: group: pr-${{ github.event.pull_request.number }} cancel-in-progress: truePer 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 cancellingPer 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 environmentconcurrency: group: deploy-${{ github.event.inputs.environment || 'staging' }} cancel-in-progress: falseThe 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 testA 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.shTelling 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 workflowconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true
# No rights by defaultpermissions: {}
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.shProtecting 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) -> CANCELLEDWorkflow 2 (running) -> CANCELLEDWorkflow 3 (new) -> RUNSWith 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) -> FINISHESWorkflow 2 (pending) -> WAITSWorkflow 3 (pending) -> WAITSThe 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 branchconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: trueA 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 branchconcurrency: 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 flightconcurrency: group: deploy cancel-in-progress: true
# ✅ Wait for the previous deploymentconcurrency: group: deploy cancel-in-progress: falseKey points
concurrencygroups 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: truefor the CI (saving minutes),falsefor 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
mainby 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
concurrencycomes 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.