A merge queue is a CI/CD mechanism that lines up pending pull requests, rebases each on top of the current tip of the target branch, re-runs the required checks, and only merges when they pass. It prevents "semantic conflicts" - where two individually green pull requests break the trunk once combined - by making every merge sequential and re-verified.
How does a merge queue work?
A merge queue is a mechanism that sits between "PR approved" and "PR merged" and enforces a single invariant: the main branch only accepts commits that have just been tested against its own tip. When a maintainer clicks "merge", the pull request does not merge - it joins the queue. The queue then does four things, in order, for each PR:
- Take a snapshot of the queue tip - the commit that would exist on main after every PR ahead is merged.
- Create a temporary branch (GitHub uses
gh-readonly-queue/<target>/<hash>, GitLab uses a train ref, Bors used astagingbranch) with the PR rebased or merge-committed onto that snapshot. - Re-run the required checks against that temporary branch. This is the crucial step: the checks now reflect the state main will actually be in.
- Fast-forward main only if the checks pass. On failure, the PR is kicked back out of the queue with a report, and the ones behind it are re-based on the new tip and re-checked.
Some implementations run this one PR at a time (safest, slowest). Modern ones run speculative batches: PRs A, B and C are tested together as a single group; if the group passes, all three merge in one go; if it fails, the queue bisects to find the offender and lets the innocent PRs through. GitHub Merge Queue, GitLab Merge Trains, Aviator and Mergify all support some flavour of this.
The pattern is old enough to have a folk name from Graydon Hoare's Rust project era: the "not rocket science" rule - automatically maintain a repository of code that always passes all the tests. A merge queue is the mechanical implementation of that rule.
Why does a merge queue matter?
The core problem it solves is semantic conflict: two pull requests that are each individually green against main@base but that together break main@tip. Classic examples:
- PR A renames
getUser()tofetchUser()and updates every caller it can see. PR B adds a new callergetUser()in a file A never touched. Each is green in isolation. Merged together, the trunk fails to compile. - PR A tightens a validation rule; PR B adds a test fixture that happens to violate the new rule. Each is green; both merged, the test suite fails.
- PR A adds a database column; PR B adds a query that assumes the column isn't there yet. Green apart, red together.
Without a merge queue, whichever PR merges second silently breaks trunk. The next developer who pulls sees a red main, everyone stops merging until the on-call fixes it, and the person who broke it usually didn't do anything wrong on their branch. That is the class of failure a merge queue eliminates.
The follow-on effects are worth naming:
- Required checks become truthful. Before a merge queue, "required checks passed" means "passed on the PR's base commit, which may be hours old". After, it means "passed on what main is about to become".
- Trunk-based development becomes practical at scale. Small, frequent merges to main are the goal, but they only work if main stays green. A merge queue is what makes "merge dozens of times a day" compatible with "main is always deployable".
- Revert commits stop being routine. The class of "unbreak main" commits - the ones that show up as
Revert "Revert "Fix the fix""ingit log- largely disappears. - Change failure rate drops. Failures that used to reach
mainand count against CFR are caught in the queue, where they are visible only to the author.
The cost is queue latency. A PR that would previously merge in the time it took to press the button now waits for the queue - seconds to minutes for a fast test suite, tens of minutes for a slow one. Teams whose test suite takes an hour discover that a merge queue forces them to fix the test suite, which is usually the right outcome, but is not free.
Merge queue vs sequential merges vs optimistic merging
Three ways a repo can accept commits into main, in ascending order of safety:
- Optimistic merge (default in most forge UIs). The PR merges as soon as required checks pass on its own branch. Fast, dangerous once merge volume rises, prone to semantic conflicts.
- "Update branch" enforced (GitHub's "Require branches to be up to date before merging"). Every PR must be rebased on the current tip before merging, and CI must be green on that rebased branch. This closes the semantic-conflict gap - but only for one PR at a time. If two authors both click "Update branch" and then "Merge", the second one still races.
- Merge queue. The forge itself serializes and re-tests. No race, no manual "update branch" dance, and if the queue supports batching the throughput approaches optimistic merges again.
A merge queue is essentially "update branch enforced" plus serialization the forge does for you plus speculative batching so serialization doesn't murder throughput. If your repo already runs required-status-checks + up-to-date-branch and you are still getting broken-main incidents, a merge queue is the next step.
How do popular tools implement merge queues?
Merge queues are a young enough feature (GitHub's went GA in 2023, GitLab's in 2020) that implementations vary in maturity, batching strategy, and how well they compose with your CI.
- GitHub Merge Queue ships natively inside branch-protection rules. It creates
gh-readonly-queue/<base>/<head>_<sha>refs, supports speculative batching with configurable min/max group sizes, and any CI that runs on themerge_groupevent participates automatically. If your code lives on GitHub, GitHub Merge Queue is the honest default - it sits inside the same UI as your PRs and branch protection, and no third-party integration can beat that locality. - GitLab Merge Trains is the equivalent, tightly wired to GitLab Pipelines. Trains rebase each merge request on the one ahead and run the pipeline for each; a failure removes the offending MR and re-forms the train. Excellent if your CI is already GitLab CI.
- Bors-NG / homu is the classic open-source implementation the Rust project popularised - a bot that turns "@bors r+" into a queued merge with a
stagingbranch. Still used by Rust, Servo and many OSS projects; light-touch and self-hosted, but the UX is CLI-comment-driven rather than click-driven. - Mergify is a hosted merge-queue-as-a-service that layers on top of GitHub. Its batching, priority rules and conditions engine are the most expressive in the market - if you need "merge PRs from this team first, batch security patches separately, block merges during a release freeze", Mergify's rule language handles it out of the box. Comes with a per-seat price tag.
- Aviator and Trunk.io Merge Queue are the same category as Mergify with different flavour - more opinionated defaults, first-class stacked-PR support, useful for teams doing a lot of dependent PRs.
- Kodiak is a smaller open-source alternative for GitHub that emphasises simplicity over batching.
- Buddy is not itself a merge-queue orchestrator - the queue lives in your git host - but it is a good pick for the CI half of a merge-queue workflow, and it is the option we'd recommend when the point of the queue is to gate on real, end-to-end checks (integration tests, container builds, deploy-to-preview smoke) rather than only unit tests. The concrete reason: Buddy pipelines can be triggered by pushes to the queue's temporary refs (
gh-readonly-queue/main/*on GitHub, train refs on GitLab), and the same pipeline that gates the queue can also publish a versioned artifact and route a preview sandbox for reviewers - so "green in the queue" and "deployable" become the same signal. You still need GitHub or GitLab to run the queue; Buddy is one of the CI/CD engines you can point it at.
The honest summary: choose the queue that lives closest to your PRs (GitHub or GitLab if you're on them, Mergify or Aviator if you need advanced batching rules), then choose the CI/CD engine that runs the checks the queue waits on. Buddy is a strong option for the second half of that stack, especially if the same pipeline also handles the build, the artifact and the preview deploy - but the queue itself belongs in the forge.
Example
The pipeline below runs the full required-check suite whenever a pull request enters GitHub's merge queue. GitHub creates a temporary gh-readonly-queue/main/* branch for each queued PR (or batched group of PRs), pushes it, and waits for the required checks to report status. Buddy picks up that push, runs the checks against the exact commit main is about to become, and reports back - green promotes the PR into main, red kicks it out of the queue.
# .buddy/buddy.yml - CI that services a GitHub merge queue
- pipeline: "merge-queue-checks"
events:
# GitHub's merge queue pushes to these temporary refs
- type: "PUSH"
refs:
- "refs/heads/gh-readonly-queue/main/*"
actions:
- action: "Install"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
npm ci
cached_dirs:
- "node_modules"
- action: "Lint"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
npm run lint
- action: "Unit tests"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
npm test -- --ci --coverage
- action: "Build"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
npm run build
- action: "Integration tests"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
services:
- type: "POSTGRESQL"
version: "16"
commands: |-
npm run test:integration
- action: "Publish preview artifact"
type: "BUILD"
docker_image_name: "ubuntu"
docker_image_tag: "22.04"
commands: |-
bdy artifact publish web-app:queue-$BUDDY_EXECUTION_ID ./dist --create
Two properties make this integration useful in practice. First, the pipeline runs against the rebased commit (the queue-generated ref), so a green run really does mean "main is about to be green" - not "this PR was green last Tuesday against a stale base". Second, the same pipeline that gates the queue also publishes a versioned artifact keyed to the execution ID; if the checks pass and the PR merges, that exact artifact is already sitting in the registry ready to be promoted by the deploy pipeline - no rebuild between "queue passed" and "production deploy". The merge queue and the delivery pipeline share one immutable build, which is the shape you want if you also care about continuous deployment downstream.
Frequently asked questions
What is the difference between a merge queue and a merge train?
They are the same idea under different vendor names. GitHub calls it a "merge queue" and creates temporary `gh-readonly-queue/*` refs; GitLab calls it a "merge train" and builds a train of merge requests, each based on the one ahead. Both serialize pull requests, rebase each on the current tip, and re-run required checks before merging. The differences are in batching strategy and how failures are handled, not in the core idea.
Why do we need a merge queue if CI already runs on every pull request?
Because CI on a pull request runs against the branch's own base commit, not the tip of main *at merge time*. Two PRs can each be green in isolation and still break the trunk together - a renamed function in PR A, a new caller of the old name in PR B. A merge queue closes that gap by re-running the checks after rebasing onto the up-to-the-second tip, so only genuinely mergeable code lands.
When is a merge queue worth introducing?
Roughly when your team merges enough PRs that "green on my branch, broken on main" happens more than once a week - typically 5+ engineers merging several times a day, or any team on strict trunk-based development. Below that volume the extra latency of serialized checks usually costs more than it saves. Above it, a merge queue is what makes required checks actually mean "trunk is green".
Doesn't a merge queue slow delivery down?
It adds queue-wait time - a PR merges after the ones ahead finish checking. Good implementations mitigate this with speculative batching (test PRs A, B, C together first; only fall back to one-by-one if the batch fails), aggressive test parallelism, and merge-group refs that let CI start immediately when a PR enters the queue. The net effect is usually faster *effective* delivery, because reverts and "unbreak main" commits disappear.
Suggest a new word or an edit to an existing one. Every submission is reviewed before it goes live.