GitHub Actions Concurrency Control
Concurrency control prevents multiple workflow runs from conflicting with each other. When several people push code at the same time, or when someone pushes multiple commits quickly, multiple workflow runs start simultaneously. Without concurrency control, two deployment runs can fight over the same server, or an older deployment can overwrite a newer one. This topic covers how to manage simultaneous runs safely.
Why Concurrent Runs Cause Problems
Picture two delivery trucks heading to the same address at the same time. Truck 1 carries version 1.5 of your app. Truck 2 carries version 1.6. If Truck 2 arrives first but Truck 1 arrives five minutes later, version 1.5 overwrites version 1.6 — and your users get the older version.
Without concurrency control:
Push commit A → Run 1 starts: test → build → deploy
Push commit B → Run 2 starts: test → build → deploy
↑
Run 1 deploys AFTER Run 2
→ Old code overwrites new code!
The concurrency Key
The concurrency key at the workflow or job level defines a concurrency group. Only one run with the same group name can be active at a time.
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
With this configuration:
- If no other run with the group name
deploy-productionis active, this run proceeds normally - If another run is already queued or running, the behavior depends on
cancel-in-progress
cancel-in-progress: true vs false
cancel-in-progress: true
When a new run starts:
→ The currently running/queued run is CANCELLED
→ The new run proceeds immediately
Best for: deployments, preview environments, anything where
only the LATEST run matters
cancel-in-progress: false (default)
When a new run starts:
→ It waits in a QUEUE behind the current run
→ Runs execute one at a time in order
Best for: jobs that must all complete (release builds,
database migrations)
cancel-in-progress: true
Commit A pushed → Run 1 starts
Commit B pushed → Run 1 CANCELLED, Run 2 starts
Commit C pushed → Run 2 CANCELLED, Run 3 starts
Result: Only Run 3 (latest) completes
cancel-in-progress: false
Commit A pushed → Run 1 starts
Commit B pushed → Run 2 waits for Run 1
Commit C pushed → Run 3 waits for Run 2
Result: All three runs complete, in order
Dynamic Concurrency Groups
Use expressions to create group names based on branch, pull request number, or other context values. This allows different branches or PRs to run simultaneously while still controlling concurrency within each one.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This creates separate concurrency groups for each branch:
Branch: main → group: "deploy-main"
Branch: staging → group: "deploy-staging"
Branch: feature → group: "deploy-feature-login"
Pushes to main and staging can run in parallel because they have different group names. Multiple pushes to the same branch cancel each other, keeping only the latest.
Per-Pull-Request Concurrency
For CI workflows, create one concurrency group per pull request so each PR's runs do not interfere with other PRs:
concurrency:
group: ci-${{ github.event.pull_request.number }}
cancel-in-progress: true
PR #42: multiple pushes → only latest run for PR #42 is active
PR #43: multiple pushes → only latest run for PR #43 is active
PR #42 and PR #43 run in parallel with each other
Job-Level Concurrency
You can set concurrency at the job level instead of the workflow level. This is useful when only one specific job (like the deploy job) needs protection:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test ← No concurrency limit here
deploy:
needs: test
runs-on: ubuntu-latest
concurrency: ← Concurrency only on deploy
group: deploy-prod
cancel-in-progress: true
steps:
- run: ./deploy.sh
Multiple test jobs can run in parallel. Only the deploy step is serialized.
Preventing Concurrent Database Migrations
Database migrations must run one at a time. Running two migrations simultaneously can corrupt data. Use cancel-in-progress: false to queue them safely:
jobs:
migrate:
runs-on: ubuntu-latest
concurrency:
group: db-migrations-production
cancel-in-progress: false ← Queue, never cancel
steps:
- run: npm run db:migrate
Concurrency vs Timeout
Feature | Concurrency | Timeout
--------------------|--------------------------|---------------------------
Prevents | Simultaneous runs | Stuck or runaway jobs
Acts on | Multiple workflow runs | Single job or step
Configured with | concurrency key | timeout-minutes key
Set a timeout-minutes on jobs to prevent a stuck job from blocking the concurrency queue indefinitely:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30 ← Kill job if it runs over 30 minutes
concurrency:
group: deploy-prod
cancel-in-progress: false
steps:
- run: ./deploy.sh
