Skip to content
Mehdi Abaakouk Mehdi Abaakouk
June 17, 2026 · 9 min read

We Built a Merge Queue Mode That Skips the Safety Check on Purpose

We Built a Merge Queue Mode That Skips the Safety Check on Purpose

Mergify's merge queue now has three modes: serial, parallel, and isolated. How each one trades safety, latency, and throughput, and why the fastest mode drops the cross-batch conflict check with a risk you can compute.

A merge queue is a bet on three things you can’t fully have at once: safety, latency, and throughput. We just shipped two new ways to place that bet, and the most interesting one works by giving up some of that safety on purpose.

What a merge queue is actually protecting you from

The whole point of a merge queue is that nothing lands on your main branch until it has passed CI against the code that’s really going to be there. You don’t test a pull request against the main it was opened from. You test it against the main it’s about to become part of, including every other PR merging ahead of it.

That matters because of a failure mode that plain branch protection can’t catch. Two pull requests touch completely different files, neither conflicts with the other in the git sense, and both pass CI on their own. You merge both, and main is broken, because one of them changed behavior that the other one’s tests quietly depended on.

That’s a semantic conflict. No overlapping lines, no merge marker, just a broken build that nobody’s individual PR predicted. A merge queue exists to catch exactly that class of bug before it reaches everyone else.

The expensive part is how you catch it. The mode you pick decides how much you pay for it.

Serial: catches everything, makes you wait

The default mode is serial. Pull requests are tested cumulatively, each batch built and tested on top of the one before it, all the way down the line:

flowchart LR
    M[main] --> B1["batch&nbsp;1<br/>tested on: main"]
    B1 --> B2["batch&nbsp;2<br/>tested on: main + batch&nbsp;1"]
    B2 --> B3["batch&nbsp;3<br/>tested on: main + batches&nbsp;1 and&nbsp;2"]

This catches every semantic conflict there is, because by the time a batch is tested, it’s sitting on top of the exact state of main it will merge into. If an earlier batch broke something a later one relies on, the later batch’s CI goes red before it merges.

The cost is latency. The checks run speculatively (batch three’s CI doesn’t wait for batch two’s to finish), but every batch still bets on everything ahead of it merging. When something in the middle of the line fails and gets kicked out, everything behind it gets rebuilt against the new, shorter line, and you wait again. For most repositories this is the right default, and it’s the one you should keep if your pull requests regularly touch the same code. Those are the teams that semantic conflicts actually bite, and serial is what protects them.

Parallel: stop serializing changes that have nothing to do with each other

In a large monorepo, most pull requests don’t interact. A change to the billing service and a change to the marketing site have no business waiting in the same line. parallel mode lets them run at the same time.

The mechanism is scopes. A scope is a label describing what part of the codebase a PR touches. Two PRs whose scopes don’t overlap are tested concurrently, in independent lanes. Two PRs whose scopes do overlap still depend on each other, exactly like serial, because they might actually conflict:

flowchart LR
    M[main] --> A1["batch&nbsp;1 · api<br/>tested on: main"]
    A1 --> A2["batch&nbsp;2 · api<br/>tested on: main + batch&nbsp;1"]
    M --> W1["batch&nbsp;3 · web<br/>tested on: main"]

The two api batches form a chain. The web batch shares no scope with them, so it runs in its own lane, straight off main.

With correctly configured scopes, parallel mode isn’t a safety compromise. If your scopes accurately describe what each PR affects, a semantic conflict between two non-overlapping PRs won’t happen in practice. You get concurrency and you keep the guarantee.

The weight is all on “correctly configured.” A scope is an assertion about a PR’s blast radius, and semantic conflicts exist precisely because blast radius isn’t always visible from the files you changed. Declare a PR narrower than it really is, and two PRs that actually interact end up in separate lanes, never tested together. That miss is silent, and it has no bound. Parallel is exactly as strong as your scopes are accurate.

Getting scopes right is real work, which is why the third mode exists.

Isolated: maximum throughput, and a risk you can measure

isolated mode is for teams that want a queue to batch their pull requests and are happy to trade catching every semantic conflict for speed.

In isolated mode, every batch is tested against the latest version of main at the moment the batch is created, and no batch depends on any other. There are no scopes to compute and no chain to wait on. By default, batches are grouped by changed-file similarity, so you still get sensible batching, and then they all run independently:

flowchart LR
    M["main<br/>(at batch creation)"] --> I1["batch&nbsp;1<br/>tested on: main@T0"]
    M --> I2["batch&nbsp;2<br/>tested on: main@T0"]
    M --> I3["batch&nbsp;3<br/>tested on: main@T0"]

This is the mode that drops the cross-batch semantic conflict check. If batch A breaks a test that batch B depends on, and the two were created around the same time, isolated mode will merge both. Serial would have caught it. Isolated won’t.

We shipped it anyway because the risk is bounded, and the bound is small.

Every isolated batch is tested against main as it stood when the batch was created, not against the other in-flight batches. A batch only merges stale if other batches land between its creation and its merge. The worst case is when several batches all get created before any of them merges, so they share the same base:

batch 1 created  (tested against main@T0)
batch 2 created  (tested against main@T0)
batch 3 created  (tested against main@T0)
batch 1 merged   -> main moves
batch 2 merged   -> main moves again
batch 3 merges, having never seen batch 1 or batch 2

max_parallel_checks caps how many batches run at once, so a batch can only go stale against the PRs in the other in-flight batches. As long as batches merge in roughly the order they were created, that gives a clean bound: (max_parallel_checks - 1) * batch_size pull requests. With the defaults (max_parallel_checks of 5, batch_size of 1) that’s a worst-case window of 4 PRs. Bump batch_size to 5 and it grows to 20. (A batch whose CI runs much longer than its neighbors’ can see more merge past it, so the bound describes the typical window, not a law of physics.) Within that window, you’re trusting that no semantic conflict slipped through.

The bound caps how many PRs can be stale relative to each other. Whether a conflict really bites inside that window is a separate question, and in our experience the odds are low: once a merge queue is keeping your main green, most pre-merge CI failures turn out to be flaky tests, not real semantic conflicts. The rare one that slips through breaks main visibly, and you revert it.

That’s the whole pitch. Serial would catch the conflict that isolated misses, but it makes every batch wait for the ones ahead of it. Isolated trades a small, bounded staleness window for batches that merge as fast as CI can run them, no waiting in line. For independent pull requests, where that conflict was never going to happen anyway, serial just makes you pay latency for a guarantee you don’t need.

The three modes, side by side

The difference is entirely in how batches depend on each other. Serial is one chain. Parallel is a graph where only overlapping scopes draw an edge. Isolated has no edges at all.

ModeEach batch builds on the previous?Runs batches concurrently?Best for
serialYes, each batch builds on the one before itSpeculatively, in one chainMost repos; PRs that often touch the same code
parallelOnly when scopes overlapYesMonorepos where PRs usually touch independent areas
isolatedNeverYesIndependent PRs where you want maximum throughput

The same axis runs through all three. Serial buys maximum safety with latency. Parallel buys concurrency back without losing safety, if you do the work to define scopes. Isolated buys maximum throughput by spending a bounded amount of safety.

If you’re coming from GitHub’s native merge queue, here’s the map. Their model is serial, and ours is the same idea with sharper edges. They have no equivalent of parallel at all, and none of isolated either: even with their build concurrency cranked up, every merge group still carries the changes of the groups ahead of it. Dropping that dependency is the one trade GitHub won’t let you make.

Where scopes come from

Since parallel is only as good as its scopes, the obvious question is how you get scopes accurate enough to trust. There are two ways.

The first is file pattern matching. You define scopes as include and exclude glob lists, and Mergify works out a PR’s scopes from the files it changes. This lives in your Mergify config and covers most cases.

The second is more interesting for large monorepos. You push scopes to us through the API, and we have tooling to plug into build systems like Bazel and Nx. Those tools already maintain a precise dependency graph of your repository. They know that touching this library affects those seventeen targets and nothing else. Feed that graph into your scopes and you get the kind of accuracy where parallel mode’s safety guarantee holds. The scope boundaries match the real blast radius of a change instead of a human’s guess at it.

That’s the path we’d point a serious monorepo team toward. Isolated is the pragmatic choice when you haven’t built that yet, or when your PRs are independent enough that the bounded risk just doesn’t matter.

How to choose

Pick serial if your pull requests often touch the same code. The latency is the price of never shipping a broken main, and for most repositories it’s worth it.

Pick parallel if you’re in a monorepo where most PRs touch independent areas, and you’re willing to invest in scopes. Done right, it’s the only mode that gives you concurrency without giving up safety.

Pick isolated if your pull requests really are independent, you want them to merge as fast as CI allows, and a worst-case staleness window of (max_parallel_checks - 1) * batch_size PRs is a risk you’re happy to carry.

Setting a mode is one key:

merge_queue:
  mode: isolated
queue_rules:
  - name: default
    batch_size: 5

The full reference for all three modes lives at docs.mergify.com/merge-queue/queue-modes.

Picking the right unsafety

A merge queue is a bet on safety, latency, and throughput, and serial, parallel, and isolated are three ways to place it. Once you can name the trade-off, choosing becomes easy.

The mistake is assuming the safest mode is always the right one. Sometimes the right answer is a small, measured amount of unsafety, bought knowingly, in exchange for shipping faster.

Merge Queue

Tired of broken main branches?

Mergify's merge queue tests every PR against the latest main before merging. Try it free.

Learn about Merge Queue

Recommended posts

Merge Queue & CI

How we run parallel merge queues on our own monorepo

June 11, 2026 · 5 min read

How we run parallel merge queues on our own monorepo

In a monorepo, one merge queue makes everyone wait in a single line. Scopes let Mergify batch related pull requests, reuse CI, and run unrelated changes in parallel. Here's how we set it up on our own repo.

Julien Danjou Julien Danjou
Merge Queue & CI

How to merge main into a branch in Git (and when to rebase instead)

June 1, 2026 · 6 min read

How to merge main into a branch in Git (and when to rebase instead)

Your feature branch is behind main and you need to catch up before opening a PR. Here are the two commands that solve it, the trade-off between merge and rebase, and the conflict cases that bite people every time.

Julien Danjou Julien Danjou
225 Self-Hosted GitHub Actions Runners: Why We Picked Docker Over VMs
May 13, 2026 · 14 min read

225 Self-Hosted GitHub Actions Runners: Why We Picked Docker Over VMs

Our GitHub Actions bill hit $400 a day. We moved CI in-house to three bare-metal hosts, tuned libvirt VMs to match, and then dropped the VM tier entirely for Docker. Here's the path, the moments that decided it, and what bit us along the way.

Mehdi Abaakouk Mehdi Abaakouk