Skip to content

GitHub Merge Queues: How They Work and When You Need One

Lesson 6 of 12Intermediate → Advanced13 min readGitHub Engineering · Pull RequestsVerified: GitHub.com documentation, August 2026

A merge queue exists to close one specific gap: a pull request’s checks were run against a base branch that is no longer the base branch.

If you have never seen that gap cause a problem, you probably do not need a merge queue. If your main breaks regularly despite every pull request passing CI, this is very likely why.

Two pull requests, both green, both approved, neither conflicting with the other in Git’s view.

main: A ──────────────────────────
PR#1: ╲── B tested against A ✓
PR#2: ╲── C tested against A ✓

PR#1 merges. main is now A─B. PR#2 still says green — but its checks ran against A, and it is about to land on A─B. Nobody has ever tested A─B─C.

If B renames a function that C calls, or B adds a test that C breaks, or the two change the same config in incompatible ways, main is now broken. Both pull requests were green. Neither author did anything wrong. Git reported no conflict, because Git compares text and this is a semantic conflict.

Requiring branches to be up to date before merging forces each pull request to re-run its checks against current main.

That works, and it creates a race. On a repository merging ten pull requests an hour with a fifteen-minute test suite, by the time your update finishes, main has moved again. Everyone updates, everyone re-runs, and merges arrive at whatever rate the race allows. The CI bill rises and throughput falls.

A merge queue solves the same correctness problem without the race, by making merging a serialised, automated process rather than a contest.

When a pull request is ready, it is added to the queue rather than merged. GitHub then:

  1. Creates a temporary branch containing the current base plus the queued pull request’s changes — and, if others are ahead in the queue, their changes too. This is a merge group.
  2. Runs the required checks against that merge group.
  3. If they pass, merges the pull request into the base branch.
  4. If they fail, removes the offending pull request from the queue and rebuilds the remaining groups without it.

Processing is first-in, first-out, and the queue speculatively builds groups ahead of time so several pull requests can be validated concurrently rather than strictly one after another.

Queue positions build on the changes ahead of them

Three queued pull requests. The first merge group contains the base branch plus PR one. The second contains base plus PR one plus PR two. The third contains base plus PRs one, two and three. Each group is tested as the state that would exist if everything ahead of it merged.

Queue positionMerge group tested1st — PR #1main + #12nd — PR #2main + #1 + #23rd — PR #3main + #1 + #2 + #3Each group is the state that would exist if everything ahead of it merged.

The key property: the thing that gets tested is the thing that gets merged. No gap between validation and landing.

This is the part that catches teams out. A merge group is not a pull request, and workflows triggered only by pull_request will not run against it.

Workflows must respond to the merge_group event:

on:
pull_request:
branches: [main]
merge_group:

Without that trigger, the required checks never report on the merge group, and pull requests sit in the queue until they time out. The symptom — “the queue is stuck and nothing is happening” — almost always means this.

The queue is enabled as a branch requirement — historically the “Require merge queue” branch protection setting, and equivalently expressible in a ruleset.

The tuning options that matter:

SettingEffect
Build concurrencyHow many merge groups are validated at once (1–100)
Minimum / maximum pull requests to mergeBatch size bounds (1–100 each)
Wait time for minimumHow long to wait for a batch to fill before proceeding
Merge methodMerge, squash or rebase, as elsewhere

Batching trades isolation against throughput. Merging five pull requests as one group runs the test suite once instead of five times — but if the group fails, GitHub must determine which member caused it, which costs time. Small batches isolate failures; large batches save CI minutes.

Start with a batch size of one until the queue is working, then increase it if CI cost is the constraint.

For authors, almost nothing changes:

Terminal window
gh pr merge PULL_NUMBER --squash

On a queue-enabled branch this adds the pull request to the queue rather than merging it. The pull request merges later, automatically, once its group passes.

That behavioural difference is worth telling a team about explicitly. People expect the merge button to merge; here it enqueues, and the actual merge happens minutes later without further action.

Its group failed, so it is removed from the queue and the remaining groups rebuild without it. The author fixes the problem and re-queues.

The important consequence: one bad pull request does not block the queue indefinitely. It is ejected and everything behind it continues. This is the property that makes queues viable on busy repositories.

Diagnosing a failure means reading the checks on the merge group rather than on the pull request — the pull request’s own checks may still be green, because they ran against a different base.

Merge queues add latency, configuration and a concept your team must understand. That cost is worth paying under specific conditions.

Strong signals you need one:

  • main breaks despite all pull requests passing CI
  • Multiple pull requests merge per hour, with a test suite of meaningful length
  • “Update branch” has become a treadmill people complain about
  • A broken main blocks everyone, because deployment follows it

Strong signals you do not:

  • A handful of merges per week
  • A test suite measured in seconds
  • One or two people who coordinate naturally
  • A main that is not continuously deployed

The honest summary: merge queues are for repositories where merge rate multiplied by CI duration is high enough that the race matters. Below that threshold, requiring up-to-date branches gives you the same correctness with far less machinery.

Queues fit trunk-based development almost definitionally: many small changes, integrated frequently, into one always-releasable branch. That is precisely the pattern that creates the stale-validation problem, and precisely the pattern the queue protects.

They fit long-lived feature branches poorly. If changes integrate weekly, the race does not exist, and the queue adds latency to solve a problem you do not have. Short-Lived Branches is the prerequisite discipline — a queue does not fix an integration strategy, it optimises one that is already working.

Getting CI right is where merge queue adoptions succeed or stall, so it is worth being concrete.

A workflow serving both pull requests and the queue:

name: CI
on:
pull_request:
branches: [main]
merge_group:
types: [checks_requested]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci
- run: npm test

The merge_group trigger is what makes the queue work. Without it the required check never reports against the merge group, and pull requests sit in the queue until they time out — the symptom being “the queue is stuck”, with no error anywhere.

Some jobs should run on one trigger and not the other:

jobs:
quick-checks:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm run lint
full-suite:
runs-on: ubuntu-latest # both events
steps:
- uses: actions/checkout@v7
- run: npm test

Linting gives an author fast feedback and adds nothing at merge time, since it cannot be affected by combination with other changes. The full suite must run in the queue, because that is the entire point.

Terminal window
gh api graphql -f query='
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
mergeQueue(branch: "main") {
entries(first: 20) {
nodes {
position
state
pullRequest { number title }
}
}
}
}
}' -F owner=OWNER -F repo=REPO

Merge queue state is GraphQL-only — there is no equivalent REST endpoint — which is a good example of the coverage asymmetry described in GraphQL API.

The state field distinguishes queued, awaiting checks, mergeable and unmergeable, and reading it is how you answer “why has my pull request been in the queue for twenty minutes” without guessing.

The defaults work; the settings that matter once you have traffic:

Build concurrency is how many merge groups are validated simultaneously. Higher means more throughput and more runner minutes, since speculative groups that get invalidated are wasted work.

Maximum pull requests to merge is batch size. Batching five changes runs the suite once instead of five times — a large saving — but a failure in a batch of five requires determining which member caused it, which costs a round of bisection.

Minimum to merge, and the wait timeout, control whether the queue waits to fill a batch. Waiting adds latency to the first pull request in exchange for efficiency across the batch.

The reasonable progression: start with concurrency 1 and batch size 1 until the queue demonstrably works, then increase batch size if CI cost is the binding constraint, or concurrency if latency is.

Changing both at once while diagnosing a problem makes the diagnosis harder.

Queues are usually presented as unambiguously good. They have real costs worth stating.

Latency. A merge is no longer immediate. On a repository with a twenty-minute suite and a queue of five, the last one waits well over an hour. For a team used to pressing merge and moving on, that is a noticeable change in feel.

Runner minutes. Speculative validation runs suites that are discarded when an earlier entry fails. The correctness is paid for in compute.

A concept to learn. “The merge button does not merge” needs explaining, as does reading merge group checks rather than the pull request’s own.

A new failure mode. Queue misconfiguration is a category of problem that did not exist before, and its symptom — nothing happening — is unhelpfully quiet.

None of these outweigh a main that breaks daily. All of them outweigh a main that breaks twice a year.

No merge_group trigger. The most common failure; the queue appears stuck.

Requiring a check that cannot run on merge groups. Permanently pending.

A wildcard branch pattern. Merge queues require the branch named exactly.

Enabling it on a low-traffic repository. Latency with no benefit.

Large batches from day one. Failure attribution becomes slow and confusing.

Expecting the merge button to merge. It enqueues; tell your team.

Debugging a failure by reading the pull request’s checks. The merge group’s checks are the ones that failed.

Enabling a merge queue on a busy repository changes daily behaviour for everyone, so the order matters.

  1. Add merge_group to every required workflow and merge that change first. Nothing else works until this is in place, and it is harmless on its own.
  2. Verify each required check can report on a merge group. External checks especially.
  3. Announce the behaviour change. “The merge button will now queue rather than merge” is the whole message, and skipping it produces a day of confused reports.
  4. Enable the queue with batch size 1 and concurrency 1.
  5. Watch the first day. Most problems appear immediately as pull requests sitting in the queue.
  6. Tune batch size or concurrency once it is demonstrably working.

Step 1 before step 4 is the part people get wrong. Enabling the queue first means every pull request enters a state where its required checks will never report, and the repository stops merging entirely.

The symptom is always the same — a pull request sitting in the queue — and there are four causes.

No merge_group trigger. The overwhelmingly most common. Check the workflow file on the default branch, not your working copy.

A required check that cannot run on merge groups. An external service reporting only on pull_request webhooks.

A required check with a path filter. It does not run for this merge group, so it never reports.

A wildcard branch pattern. Merge queues require the branch named exactly in the protection rule.

Terminal window
# What is required?
gh api "repos/OWNER/REPO/branches/main/protection/required_status_checks" --jq '.contexts'
# What actually ran on the merge group?
gh run list --event merge_group --limit 10 \
--json databaseId,displayTitle,conclusion,workflowName

If the second command returns nothing at all, the answer is the first cause. That single check resolves most stalls.

A merge queue changes what “merged” means for anything downstream.

Without a queue, a merge commit appears on the base branch immediately when someone presses the button. With one, the merge happens later, unattended, when the merge group passes — which means:

Deploy workflows should trigger on push to the base branch, not on pull request closure. The pull request closes when it enters the queue on some configurations, which is earlier than the merge.

Release automation must handle unattended merges. Nobody is watching when the merge lands, so a failure in a post-merge workflow needs to alert rather than rely on being noticed.

Batched merges land several changes in one push. A deploy triggered per push may deploy five changes at once, which is fine and worth knowing when reading a deployment log.

None of these are difficult; they are simply different from the pre-queue assumptions most repositories accumulate, and they surface as surprises rather than errors.

A merge queue needs concurrent traffic to demonstrate anything, so this exercise is mostly reasoning.

  1. On a practice repository, open two pull requests that do not conflict textually but do conflict semantically — one renames a function, the other adds a call to the old name.
  2. Confirm both pass CI and neither reports a Git conflict.
  3. Merge both and observe that main is now broken.
  4. Reason through what a merge group containing both would have caught, and at which step.
  5. If you have a repository with the plan for it, enable a merge queue, add the merge_group trigger, and repeat.

Steps 1 to 3 are the whole justification for merge queues, reproduced in about ten minutes. It is worth doing once, because the problem is much more convincing when you have caused it yourself.

  • A merge queue closes the gap between what CI validated and what actually lands.
  • Semantic conflicts pass Git’s textual conflict detection and fail at build time.
  • Requiring up-to-date branches fixes correctness but creates a race at high merge rates.
  • A merge group is base plus everything ahead in the queue; the tested state is the merged state.
  • Workflows must handle the merge_group event or the queue stalls.
  • Failing pull requests are ejected rather than blocking the queue.
  • The threshold is merge rate multiplied by CI duration — below it, a queue is machinery you do not need.

A merge queue earns its complexity when merge rate multiplied by CI duration is high enough that the stale-validation race actually costs you.

Concretely: if main breaks despite every pull request passing CI, and updating branches has become a treadmill people complain about, you have the problem a queue solves.

If you merge a handful of times a week, or the suite runs in ninety seconds, you do not. Requiring branches to be up to date gives the same correctness guarantee with no queue to configure, no concept for the team to learn, and no merge_group trigger to forget.

The intermediate case is worth naming: frequent merges with a fast suite are fine without a queue, because the race window is small. It is the combination that matters, not either factor alone.

A queued pull request sometimes needs to come out — the change turns out to be wrong, or something more urgent needs to go first.

Terminal window
gh api graphql -f query='
mutation($id: ID!) {
dequeuePullRequest(input: {pullRequestId: $id}) {
mergeQueueEntry { position state }
}
}' -F id="$PR_NODE_ID"

Removing an entry invalidates the speculative merge groups built on top of it, so everything behind it is revalidated. That is the correct behaviour — those groups were tested against a state that will now not exist — and it means dequeuing during a busy period costs real CI time.

There is no priority mechanism. The queue is first-in, first-out, and the only way to get something in front of the queue is to remove the entries ahead of it. For a genuine emergency the answer is usually a bypass on the ruleset rather than fighting the queue — which is precisely the case a documented break-glass procedure exists for.

Terminal window
gh api graphql -f query='
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
mergeQueue(branch: "main") {
entries(first: 20) { nodes { position state pullRequest { number } } }
}
}
}' -F owner=OWNER -F repo=REPO

Reading the queue before acting is worth the extra call: the entry may already have merged, and dequeuing something that no longer exists returns an error that reads more alarmingly than it is.

Professional ToolkitCODEOWNERS, pull request and issue templates, and repository configuration checklists ready to adapt.