Zum Inhalt springen
DevOps

Stacked pull requests on GitHub: slicing large changes into reviewable layers

A stack is an ordered chain of pull requests. Every layer builds on the one before it, and only the foot of the chain touches main. So rather than reviewing one large change, you review several small ones, each with its own diff and its own discussion. Since 30 July 2026 GitHub does this natively, as a public preview, without any third-party tooling.

This article is a complete tutorial built around one running example: per-tenant invoice numbering, four layers, from the database migration to the nightly job. It covers creating the stack, the cascading rebase, merging from the bottom up and the condition that comes with all of it, the one you miss on your first attempt.

Contents

The problem: a review in which the migration disappears

The feature sounds harmless. Every tenant gets its own invoice numbering, sequential, per year, with its own prefix. In domain terms that is four things building on each other: a table and a column in the database, the numbering logic, an endpoint for configuring the number range, and a nightly job that retroactively covers invoices without a number.

It gets built on one branch. In the end it looks like this:

$ git diff --stat main
 87 files changed, 1284 insertions(+), 96 deletions(-)

This pull request goes into review and sits there. Not out of malice: anyone opening 87 files looks for a way in, and the easiest way in is always the one where you can quickly say something useful. That means the names in the DTO, the order of the fields, a missing final.

The risky part is somewhere else. It lives in a single file:

databaseChangeLog:
  - changeSet:
      id: 2026-08-01-invoice-numbering
      author: velaatlas
      changes:
        - createTable:
            tableName: invoice_number_range
            columns:
              - column:
                  name: id
                  type: bigint
                  autoIncrement: true
                  constraints: { primaryKey: true }
              - column:
                  name: tenant_id
                  type: bigint
                  constraints: { nullable: false }
              - column:
                  name: prefix
                  type: varchar(16)
                  constraints: { nullable: false }
              - column:
                  name: current_value
                  type: bigint
                  defaultValueNumeric: 0
                  constraints: { nullable: false }
        - addColumn:
            tableName: invoice
            columns:
              - column:
                  name: invoice_number
                  type: varchar(32)
                  constraints: { nullable: false }

This migration is the only part of the change that you cannot take back by reverting code. It runs against a table holding existing data, and nullable: false without a default fails there, because the existing rows have no invoice number. That exact mistake is very likely to survive an 87-file review, because it sits between busywork.

So the problem is not the diligence of the reviewers. It is the portioning.

The idea: a chain instead of a lump

A stack inverts the portioning. Instead of one branch there are four, and each targets its predecessor instead of main:

main
 └─ numbering-schema           PR 1   migration, column nullable
     └─ numbering-domain       PR 2   numbering logic in the service
         └─ numbering-api      PR 3   endpoint and DTO
             └─ numbering-job  PR 4   nightly job for existing data

GitHub calls the chain a stack, each link a layer, and the target branch at the very bottom the trunk. Each pull request’s diff contains only the change of that one layer, because its point of comparison is the branch below it and not main.

For the review this changes the starting position. The pull request holding the migration contains one file. Whoever opens it cannot talk about anything else.

For the author it changes the order of decisions: the slicing happens before the first commit exists. That is the actual effort involved, and no tool takes it off your hands.

Prerequisites

Stacked pull requests have been in public preview since 30 July 2026 and are rolling out across all repositories. You can work with them on github.com, through the GitHub CLI and in the mobile app. Coding agents such as GitHub Copilot reach them through the gh-stack skill.

For the local workflow you need the extension:

gh extension install github/gh-stack

It requires GitHub CLI 2.90.0 or later and Git 2.20 or later. Check your versions with:

gh --version
git --version

Also worth having in place before you start as a team: branch protection rules on the default branch and the Actions workflows that run on pull requests against the default branch. Both apply inside the stack, and you want to have seen both on a test stack before they hit a real feature.

Step 1: create the stack

gh stack init creates the stack locally and sets up the first layer. Without arguments the command prompts interactively for a branch name and offers to use the current branch as the first layer.

git switch main
git pull
gh stack init --base main

--base defines the trunk, the branch the bottom layer will target. In most repositories that is main, in some it is develop. Without the flag, init asks interactively for the name of the first layer and offers to use the current branch for it. In the example below that first layer is called numbering-schema.

Step 2: the migration as the bottom layer

At the very bottom sits whatever everything else needs. Here that is the migration. And because it sits at the bottom, it has to have a property it did not need inside the large pull request: it must work on its own, without the code from the layers above.

That is why the changeset looks different inside a stack than it did above. The table stays as it was, only the end of it changes:

        - addColumn:
            tableName: invoice
            columns:
              - column:
                  name: invoice_number
                  type: varchar(32)

The difference is one line: constraints: { nullable: false } is gone, the invoice_number column is now nullable. That lets the migration run through against existing data, and the state afterwards is a valid state of the system, even if no further row is ever added.

Commit and done:

git add src/main/resources/db/changelog/
git commit -m "Number range table and nullable invoice number"

Step 3: stacking the layers above

gh stack add puts a new branch on top of the stack. With -A the changes are staged along the way, with -m they are committed:

gh stack add numbering-domain

Then write the numbering logic:

@Service
class InvoiceNumberService {

    private final NumberRangeRepository ranges;

    InvoiceNumberService(NumberRangeRepository ranges) {
        this.ranges = ranges;
    }

    @Transactional
    String nextNumber(long tenantId, int year) {
        NumberRange range = ranges.lockByTenant(tenantId)
                .orElseThrow(() -> new NoNumberRangeException(tenantId));

        long value = range.increment();
        return "%s-%d-%05d".formatted(range.prefix(), year, value);
    }
}

And commit:

git add src/main/java/
git commit -m "Hand out invoice numbers per tenant"

The next two layers are created the same way. gh stack add comes with a short form that stages, commits and creates the branch in a single step:

gh stack add numbering-api -Am "Endpoint for maintaining number ranges"
gh stack add numbering-job -Am "Nightly job assigns numbers to existing invoices"

A look at the current state:

gh stack view

The command shows the chain with its branches, the position within the stack and, once they exist, the associated pull requests. --short keeps the output brief, --json makes it machine readable.

Step 4: create the pull requests

Up to this point everything is local. gh stack submit pushes all branches, creates the missing pull requests and links them into a stack on GitHub:

gh stack submit

The difference between gh stack push, gh stack submit and gh stack sync is worth memorising once:

Command Pushes branches Creates missing pull requests Rebases onto the trunk
gh stack push yes no no
gh stack submit yes yes no
gh stack sync yes never yes

gh stack sync is the everyday command for when main has moved. It fetches from the remote, fast-forwards the trunk, rebases the chain onto it, pushes the updated branches and syncs the pull request state. It never opens new pull requests, that is what submit is for.

With --auto, submit enables auto-merge; with --open it opens the pull requests in the browser.

Step 5: what becomes visible on GitHub

On GitHub every pull request in the stack carries an overview of the entire chain, including its own position in it. Reviewers need no extra account and no browser extension for that, because the display is part of the pull request interface itself.

In practice that means: the reviewer of the migration sees one file, and next to it the information that three further layers build on top of it. They can judge the migration without having read the rest, and they still see what it belongs to.

If you are coming from Graphite or a comparable service, the presentation will look familiar. The difference is that no additional service sits between the repository and the review, and nobody on the team has to install anything to see the chain.

Step 6: a change at the very bottom

Now comes the part people underestimate on their first stack. The review of the migration concludes that an index on tenant_id is missing. The change belongs at the very bottom, so on the first layer:

gh stack bottom

That jumps you to the bottom layer. Make the change, commit:

git add src/main/resources/db/changelog/
git commit -m "Index on tenant_id"

And then everything above has to pick that change up:

gh stack rebase

This is a cascading rebase. The chain is worked through from the bottom upwards, each layer landing on its freshly updated predecessor. That is how a change made low down arrives everywhere above it. Then push:

gh stack push

On a conflict the rebase stops and names the affected files. You resolve it as usual and continue:

git add <file>
gh stack rebase --continue

--abort cancels and restores the original state. With --downstack and --upstack you limit the rebase to the part below or above the current layer, with --no-trunk you leave the trunk out of it.

This is where the real price of the approach sits. If something changes at the very bottom, it runs through every layer above, and a conflict affecting every layer is one you resolve that many times. That is not a flaw in the tooling, it follows from the layers building on each other. It is the reason deep stacks are rarely a good idea.

Step 7: merging from the bottom up

Merging has to happen from the bottom up. The pull request with the migration goes into main first, then the domain, then the endpoint, then the job.

Two things happen by themselves. Once the bottom layer merges, GitHub moves the rest of the chain onto the new state of main, and the next pull request then points straight at the default branch. Merge somewhere in the middle and everything above stays open and is re-pointed the same way.

Through the CLI you merge one or more layers at once:

gh stack merge --merge-method merge

The docs list all three methods, merge commit as well as squash and rebase. During the private preview, though, there were reports that squash and rebase could break the stack’s bookkeeping. Both rewrite the commits of the lower layer, and the cascading rebase builds on those. If you want squash as the repository default, try it on a test stack first. The merge queue ships separately and has not landed everywhere yet.

Afterwards you clean up locally:

gh stack sync --prune

--prune removes the local branches of the layers that have already merged and rebases the remaining ones onto the updated trunk.

The complete workflow in one piece

The whole path from main to four linked pull requests, without prose in between:

gh extension install github/gh-stack

git switch main
git pull
gh stack init --base main

# Layer 1: migration
git add src/main/resources/db/changelog/
git commit -m "Number range table and nullable invoice number"

# Layers 2 to 4
gh stack add numbering-domain -Am "Hand out invoice numbers per tenant"
gh stack add numbering-api    -Am "Endpoint for maintaining number ranges"
gh stack add numbering-job    -Am "Nightly job assigns numbers to existing invoices"

gh stack view
gh stack submit

And everyday life afterwards, when main has moved or something is added at the bottom:

gh stack sync                    # fetch the trunk, rebase the chain, sync state
gh stack bottom                  # jump to the very bottom
# ... change, git commit ...
gh stack rebase                  # pull the change through every layer above
gh stack push
gh stack merge --merge-method merge
gh stack sync --prune            # clean up local branches of merged layers

The commands at a glance

Command Purpose Notable flags
gh stack init Create a stack in the repository --base
gh stack add New layer on top of the stack -A, -u, -m
gh stack view View the stack --short, --json
gh stack checkout Check out a stack by number, pull request, URL or branch
gh stack modify Interactively restructure, drop, combine or rename layers --continue, --abort
gh stack unstack Remove a stack from tracking and unstack it on GitHub --local
gh stack submit Push branches, create or update pull requests --auto, --open, --remote
gh stack sync Fetch, rebase, push, sync state --remote, --prune
gh stack rebase Cascading rebase across the stack --downstack, --upstack, --no-trunk, --continue, --abort
gh stack push Push the active branches of the stack --remote
gh stack link Link existing pull requests into a stack on GitHub --base, --open, --remote
gh stack merge Merge one or more layers --merge-method, --yes
gh stack switch Interactively switch to another layer
gh stack up / down One layer up or down
gh stack top / bottom / trunk Jump to the top, the bottom or the trunk
gh stack alias Create a short form for a command --remove

Restructuring the order is only possible through the CLI. gh stack modify inserts layers, drops them, combines them or renames them. Teams that do not work with the CLI locally should therefore settle the order upfront, because they cannot easily reorder later.

The turning point: what merges at the bottom is in production

This is where a tool turns into a design decision.

The bottom layer merges first. It is therefore in main, and main goes into the next deployment, long before the stack is finished. Days can pass between merging the migration and merging the nightly job, and during that time production runs a state that never existed in the large pull request: the database knows the new column, the code does not.

From that follows a condition that applies to every layer: every layer has to be runnable and safe on its own. A layer must not depend on anything that only arrives two layers higher.

For the migration that means exactly what the changeset above already showed: add the column, leave it nullable, the mandatory field comes later. For the numbering logic it means it may be deployed without anyone calling it. For the endpoint it means it can sit behind a feature flag as long as the nightly job is still missing.

This is the same discipline a database migration without a maintenance window demands anyway: expand first, then migrate, then contract. Anyone already applying it has nothing new to learn for stacks. Anyone not applying it notices here for the first time, and that is the best possible moment.

A stack merely makes this problem visible, by pulling it forward out of deployment day and into the slicing.

What CI and branch protection do inside a stack

The obvious worry is that checks only hit the top layer and the rest slips through unchecked. In fact they apply on every layer.

Every layer clears the same hurdles as a standalone pull request against main: the same workflows start, the same CODEOWNERS have to approve, including for the pull request in the middle of the chain.

Four layers are therefore four full CI runs, and that is per pass. Every gh stack sync and every cascading rebase kicks the runs off again on all layers. With a busy main that means four runs per trunk movement, not four in total. This is the second price after the rebase, and it weighs on slow pipelines.

The same goes for approvals already given. The cascading rebase rewrites the branches of the upper layers, and if “Dismiss stale pull request approvals” is active in the repository, the reviews up there are gone afterwards. The more often something changes at the bottom, the more often that happens. If you work in stacks a lot, check that rule once deliberately before it shows up in daily work. Anyone introducing stacks in a repository with existing rulesets should therefore run a test stack first and look at what actually happens, rather than deriving it from the rules.

Stacked pull requests or one large pull request?

A stack does not make the work smaller. It distributes it differently, and in doing so it shifts effort from the reviewer to the author.

One large pull request Stack of several layers
Effort for slicing none, everything lands on one branch happens before the first commit
Review per unit large, attention spreads unevenly small, one topic per layer
Reviewable in parallel no, one discussion for everything yes, several reviewers at once
Reacting to a change at the bottom one more commit cascading rebase through every layer
CI runs one one per layer
State after the first merge all or nothing intermediate states go to production
Risk in the detail a single risky file disappears the risky file gets its own review

The row that decides it is the second to last. A large pull request is an all-or-nothing decision, a stack is a sequence of partial decisions. That is an advantage when the parts make sense on their own, and a disadvantage when they do not.

Stacked pull requests without the gh extension

Underneath the surface nothing new is added: branches as always, pull requests as always. The only new thing is the link GitHub stores between them, and that link does not force any particular toolchain on you.

If you manage the chain locally with a different tool, such as Jujutsu, Sapling or git-town, you hook it up on GitHub with a single command:

gh stack link

That links existing pull requests into a stack without handing local management over to gh stack. --base defines the trunk. For branches without an open pull request, link creates drafts.

This is also the answer for everyone who has been running the cascading rebase with plain Git for a while. git rebase --update-refs moves the branch tips of a chain along during a rebase, instead of making you update them one by one. The extension still has to be installed for this, but it only comes out for that one command, and everyday work stays with the tool you know.

Public preview: what is still missing

The feature is four days old, and GitHub itself states that it is subject to change. What concretely is missing or does not work today:

  • A stack ends at the repository boundary. If you work through a fork, as in the classic open source workflow, you stay with the single pull request.
  • GitHub Desktop cannot do it. If you work there, you need the CLI or the web interface for stacks.
  • Merge queue support is still rolling out. It is planned, but may not be in your repository yet.
  • Reordering only works through the CLI. Without it you settle the order upfront or rebuild the stack.
  • Server-side rebases produce unsigned commits. If your repository enforces signed commits, rebase locally with gh stack rebase, otherwise the merge fails on a rule that has nothing to do with stacks.

You should still try it. Just not on the feature that has to ship on Friday.

When a stack pays off and when it does not

A stack pays off in these cases:

  • The change has natural layers building on each other: schema, domain, interface, operations.
  • Different parts need different reviewers, for example the migration needs someone other than the DTO.
  • One part is significantly riskier than the rest and should get its own attention.
  • The lower parts are finished in themselves and can go to production while work continues above.

In these cases it does not pay off:

  • The change is one unit that can only be cut apart artificially. Then the layers are overhead without benefit.
  • The layers do not each produce a safe state in production.
  • The stack would become deep. Every additional layer costs another CI run and another round of cascading rebase.
  • The team works through forks, or the merge queue is mandatory and not yet available for stacks.
  • The feature has to ship as a whole or not at all. Then the all-or-nothing property of a large pull request is exactly what you want.

Frequently asked questions

What are stacked pull requests?
Dependent pull requests inside one repository, organised as a chain. A pull request aims not at the default branch but at the branch of the layer beneath it; only the bottom layer aims at the trunk. Each layer has its own diff and is reviewed on its own, and merging goes from the bottom up.

Do I need the gh extension?
For local management and the cascading rebase yes, for the stack itself no. Underneath it there are ordinary branches and ordinary pull requests. If you work locally with Jujutsu, Sapling or git-town, you link the chain on GitHub with gh stack link.

Does squash merge work in a stack?
The docs list it alongside merge commit and rebase, and from the CLI you pick the method with gh stack merge --merge-method. During the private preview there were reports that squash and rebase could break the stack’s bookkeeping, because both rewrite the commits. On a test stack that is settled in five minutes. The merge queue is still rolling out separately.

Can I merge a middle layer first?
Not in isolation. Merging a middle layer takes everything below it along, nothing is ever skipped. The rest above you can leave open, it is re-pointed automatically.

Do the CI checks run on every layer?
Yes. Every layer clears the same hurdles as a standalone pull request against the default branch, workflows as well as approval rules. So four layers mean four CI runs, and every rebase of the chain kicks them off again.

Do stacks work across forks?
No, a stack ends at the repository boundary. For a workflow that goes through a fork, the single pull request is all that is left.

How deep can a stack be?
GitHub states no upper limit. The practical limit follows from the effort: every layer costs its own CI run, and a change at the very bottom runs as a rebase through every layer above. You then resolve conflicts that many times.

What about signed commits?
Server-side rebases produce unsigned commits. If your repository enforces signatures, do the rebase locally with gh stack rebase before pushing.

Conclusion

Four small reviews are more pleasant than one large one, but that is not where the value of a stack sits. It sits in the question that has to be answered before the first commit exists: is every layer safe on its own?

Anyone who can answer that question has understood the change. Without an answer to it, the change would not have shipped cleanly as one large pull request either, it would just have surfaced later.

A concrete next step: do not take the next large feature, take a change you already split in two in your head anyway. Install the extension, create two layers, send them out with gh stack submit and merge them from the bottom up. The whole thing takes half an hour, and afterwards you know whether the slicing buys you anything in your repository.

Sources

All code examples are my own. The example feature (per-tenant invoice numbering) is constructed, the mechanics described are verified against the sources listed above, as of August 2026.

$ lang DE EN ES