GitHub Stacked PRs: a practical guide to gh stack
By Flavio Copes
Learn GitHub Stacked PRs with gh stack: design focused layers, submit the chain, handle review feedback, rebase safely, and merge.
GitHub Stacked PRs let you split one large change into several small pull requests that depend on each other.
Each pull request stays focused. Reviewers see one layer at a time. You can still merge the whole feature as one operation.
This solves a problem I often see with large features: the code is ready, but the pull request is too large to review well.
Instead of opening one 2,000-line pull request, you might open 3 smaller ones:
- a data model
- an API built on that model
- a user interface built on that API
The pull requests form a chain. GitHub understands that chain, shows it in the UI, runs checks for every layer, and helps keep all the branches up to date.
In this tutorial we’ll build one stack from start to finish. We’ll also change a branch in the middle, resolve a rebase conflict, and see what happens when we merge only part of the stack.
GitHub Stacked PRs is in private preview as I write this. Your repository must have the feature enabled. You can join the waitlist, and you should expect some commands or details to change before the final release.
The problem with one large pull request
Imagine we’re adding user search to an application.
The feature needs 3 parts:
- types and data access
- an API endpoint
- a search interface
We could put everything on one branch:
main
└── user-search
That gives us one large pull request.
The reviewer must understand the data layer, API, and interface at the same time. A small concern about the data model can invalidate comments already made on the UI.
We could also create 3 independent branches from main:
main
├── user-model
├── search-api
└── search-ui
But this does not model reality. The API needs the data model, and the UI needs the API.
The branches are not independent.
A stack represents that dependency:
main
└── user-model
└── search-api
└── search-ui
The first branch starts from main. The second starts from the first. The third starts from the second.
This is the central idea behind stacked pull requests.
A stack is both cumulative and divided
There are two useful ways to look at a stack.
Git sees cumulative branches. The search-ui branch contains the model, API, and UI because it descends from both lower branches.
The pull request interface shows divided changes:
PR #1: main → user-model
PR #2: user-model → search-api
PR #3: search-api → search-ui
PR #2 shows only what changed between user-model and search-api. It does not show the model again.
PR #3 shows only the UI layer.
This gives the reviewer small diffs without pretending the changes are unrelated.
What GitHub adds to the old stacked-branch workflow
Developers have stacked branches for years. You can do it with regular Git commands and ordinary pull requests.
GitHub’s feature adds a first-class Stack object around those PRs.
That gives us a few important things:
- a stack map in the pull request header
- navigation between every PR in the stack
- branch protection and CODEOWNERS checks based on the final target branch
- GitHub Actions runs for every layer as if it targeted the final branch
- a cascading rebase for the entire chain
- partial or full stack merges from one merge action
- stack data in the REST API, GraphQL API, and webhooks
The branches and pull requests are still normal Git branches and PRs. The extra stack relationship tells GitHub how they belong together.
This difference matters. A chain of PR base branches is the Git structure. The Stack object is the GitHub feature built around it.
Before you start
You need:
- Git 2.20 or later
- GitHub CLI 2.0 or later
- a GitHub repository you can push to
- Stacked PRs enabled for that repository
If you are new to the basics, read my Git guide and first GitHub pull request tutorial first.
Check that GitHub CLI is authenticated:
gh auth status
Then install the official extension:
gh extension install github/gh-stack
You can use gh stack for every command. The extension can also create a shorter gs command:
gh stack alias
I’ll use the full command in this tutorial. It makes each example easier to recognize.
Create the first layer
Start from an updated main branch:
git switch main
git pull --ff-only
Now initialize a stack and name its first branch:
gh stack init user-model
This creates and checks out user-model. It also records the new stack in .git/gh-stack.
That file lives inside .git, so it is local metadata. It is not committed to your repository.
The command also enables Git’s rerere feature. rerere means “reuse recorded resolution.” If the same conflict appears during several cascading rebases, Git can remember how you resolved it.
Let’s pretend we now add the user model and its tests. Commit the first focused change:
git add src/users.ts tests/users.test.ts
git commit -m "Add user search model"
The bottom of our stack now looks like this:
main
└── user-model
This branch should make sense on its own. A reviewer can check the model without thinking about HTTP or interface code.
Add the API layer
The API depends on the model, so it belongs above user-model.
Add a branch to the top of the stack:
gh stack add search-api
gh stack add creates the branch at the current HEAD and checks it out.
Add the API code and commit it:
git add src/api/search.ts tests/search-api.test.ts
git commit -m "Add user search endpoint"
The stack now has two layers:
main
└── user-model
└── search-api
Notice that search-api contains the model commits too. It must contain them because the endpoint uses the model.
Its pull request will not repeat them. The PR base will be user-model, so the diff only contains the API changes.
Add the interface layer
Let’s add one more branch:
gh stack add search-ui
Then add and commit the interface:
git add src/components/UserSearch.ts tests/user-search.test.ts
git commit -m "Add user search interface"
We now have the full chain:
main
└── user-model
└── search-api
└── search-ui
This is a good stack because the dependency arrows all point down.
The UI depends on the API. The API depends on the model. Nothing in user-model depends on a branch above it.
Inspect the stack before publishing it
Use gh stack view to see the current stack:
gh stack view
You can also ask for a compact list:
gh stack view --short
Or get JSON for a script:
gh stack view --json
I also like to inspect each layer with regular Git before submitting:
git diff main..user-model
git diff user-model..search-api
git diff search-api..search-ui
This is a useful test. If the last command shows changes to the data model, some work may live on the wrong branch.
Create the pull requests
Submit the stack:
gh stack submit
This command pushes all branches, creates or updates the pull requests, and links them as a Stack on GitHub.
It also gives every PR the correct direct base:
user-model → main
search-api → user-model
search-ui → search-api
The command opens an editor for the PRs. You can accept the generated titles, write clearer ones, and toggle each new PR between ready and draft.
For a non-interactive submission, use generated titles:
gh stack submit --auto
New PRs created by --auto are drafts by default. Add --open when you want them ready for review:
gh stack submit --auto --open
If you only want to push the branches, without creating or updating PRs, run:
gh stack push
The distinction is useful:
pushupdates branchessubmitupdates branches, PRs, and the GitHub Stacksyncfetches, rebases, pushes, and reconciles local and remote state
How reviewers should read a stack
A stack tells a story from the bottom up.
For our example, review in this order:
user-modelsearch-apisearch-ui
The lower layers establish concepts used above them. Starting from the UI would force the reviewer to guess how the API and model work.
GitHub shows a stack map at the top of every PR. The reviewer can jump between layers without returning to the repository’s PR list.
Each review is still independent. A reviewer can approve the model, request changes on the API, and leave the UI untouched.
My advice is to write each PR description for its own layer. Mention the larger feature, but explain exactly what that PR adds and what it deliberately leaves for the next one.
Change a branch in the middle of the stack
This is where stacked PRs usually become tedious without tooling.
Suppose a reviewer asks us to change the API while we’re working on search-ui.
Move down one branch:
gh stack down
You can also select the branch by name:
gh stack checkout search-api
Make the requested change where it belongs:
git add src/api/search.ts
git commit -m "Validate empty search queries"
Now search-ui is still based on the old tip of search-api. The history is no longer one clean chain.
Rebase every branch above the current one:
gh stack rebase --upstack
Then push the rewritten branches:
gh stack push
gh stack push uses --force-with-lease and an atomic push. The lease stops you from overwriting unexpected remote work, and the atomic push means all branch references update or none do.
This is the daily rhythm of stacked development:
change the correct layer
↓
rebase the layers above it
↓
push the updated stack
Do not put an API fix on the UI branch just to avoid a rebase. That makes the UI diff harder to understand and defeats the point of the stack.
Rebase the entire stack on main
While we work, other changes keep landing on main.
Update the entire chain with:
gh stack rebase
This performs a cascading rebase. GitHub starts at the trunk and works upward:
- rebase
user-modelonto the latestmain - rebase
search-apionto the newuser-model - rebase
search-uionto the newsearch-api
Then push:
gh stack push
You can also run the all-in-one command:
gh stack sync
sync fetches remote changes, updates the trunk, rebases the stack, pushes it, updates PR state, and reconciles the local stack with GitHub.
Use rebase when you expect to resolve conflicts interactively. Use sync for the routine case where you want everything brought up to date.
Resolve a cascading rebase conflict
A conflict in a lower layer can affect every layer above it.
Run the rebase as usual:
gh stack rebase
If Git stops, open the reported files and resolve the conflict markers.
Stage the resolved files:
git add src/users.ts
Then continue:
gh stack rebase --continue
The command continues through the remaining branches.
If the rebase is going in the wrong direction, abort it:
gh stack rebase --abort
The extension restores all branches to their pre-rebase state.
This is also where the automatically enabled rerere option helps. A resolution recorded on one branch can be reused when a similar conflict appears higher in the chain.
Rebase locally or on GitHub
GitHub also provides a Rebase Stack button in the pull request UI.
The server rebases every unmerged branch and force-pushes the result. This is convenient when the rebase is clean.
There are two reasons to prefer the CLI:
- local rebases let you resolve conflicts in your editor
- local rebases follow your Git commit-signing configuration
The server-side rebase is unavailable when conflicts need manual resolution. It also keeps the original author but sets the person who clicked the button as committer, and those new commits are not signed.
If your rules require signed commits, rebase locally.
Merge the whole stack
Once every layer is approved and passing its checks, merge the top PR.
GitHub merges that PR and every unmerged PR below it, from bottom to top.
For our stack, merging search-ui lands all 3 PRs:
user-model → search-api → search-ui
GitHub supports all 3 merge methods:
- squash merge creates one squashed commit per PR
- rebase merge replays all commits onto the target branch
- merge commit creates one merge commit for the group and preserves the branch commits
The squash behavior is particularly useful. A stack of 3 PRs becomes 3 focused commits on main, not one giant squash commit.
GitHub describes a stack merge as one atomic operation. There is a small operational nuance: its FAQ says an unexpected failure partway through can leave the lower PRs already merged while the failed PR and higher PRs remain open. Pre-merge checks reduce this risk, but there is no rollback of PRs that already landed.
Merge only part of the stack
You do not have to merge everything.
Suppose the model and API are ready, but the UI needs more work. Merge the search-api PR.
GitHub lands:
user-model → search-api
The search-ui PR stays open.
GitHub then retargets the remaining PR to main and rebases its unique commits onto the updated trunk.
You cannot merge a middle PR while leaving a lower PR unmerged. A PR always brings every unmerged layer below it.
This rule follows the dependencies. The API cannot land without the model it uses.
After a partial merge, update your local stack:
gh stack sync --prune
This syncs the remaining branches and removes local branches for merged PRs.
Once the entire stack is merged, it is complete. You cannot extend that finished Stack object. If you add new branches locally and submit them, the CLI starts a new stack from the trunk.
How squash merges avoid fake conflicts
Squash merging normally creates a problem for dependent branches.
Imagine the bottom PR contains commits A and B. The next branch contains A, B, C, and D.
After a squash merge, main contains a new commit S1 instead of A and B:
before: main ← A ← B ← C ← D
after: main ← S1
A naive rebase might try to replay A and B even though their changes are already inside S1. This can produce conflicts that are not real code conflicts. They exist because commit identities changed.
GitHub’s stack rebase finds the unique commits and uses git rebase --onto to replay only C and D on top of S1.
The remaining branch becomes:
main ← S1 ← C' ← D'
This detail is one of the strongest reasons to use stack-aware tooling. The easy part is creating several branches. The hard part is maintaining their meaning after merges rewrite history.
GitHub Actions and the CI multiplication problem
GitHub evaluates every PR as if it targeted the stack’s final base, usually main.
A workflow configured for pull requests into main runs for every PR in the stack. Required checks and branch protection apply to every layer too.
This closes a common hole in manual stacks, where only the bottom PR triggers the right checks.
It can also multiply CI usage. A stack of 5 PRs might run the same expensive integration suite 5 times.
GitHub exposes stack metadata through github.event.pull_request.stack. We can run an expensive job only for standalone PRs or the top PR, which contains the complete feature:
jobs:
integration:
if: >-
github.event.pull_request.stack == null ||
github.event.pull_request.stack.position == github.event.pull_request.stack.size
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
Keep fast checks on every layer. They give reviewers confidence in each focused change.
Reserve the most expensive end-to-end work for the top PR when that tradeoff makes sense for your project.
One subtle detail: GitHub evaluates CODEOWNERS from the stack base. If PR #1 changes CODEOWNERS, that new file does not control reviews for PR #2 yet because the change has not landed on the final base.
Bring existing branches into a stack
You do not have to start with gh stack init.
If you already have dependent branches, list them from bottom to top:
gh stack init user-model search-api search-ui
Then submit them:
gh stack submit
Existing PRs are detected and linked when possible.
If you prefer another local stacking tool, keep using it. GitHub supports standard branches and PRs, so tools such as Jujutsu, Sapling, and git-town can manage the local chain.
Use gh stack link when you only want to create the relationship on GitHub:
gh stack link user-model search-api search-ui
This does not create local gh stack tracking. It pushes branches, creates or finds their PRs, fixes the base branch chain, and links the PRs on GitHub.
Restructure a stack
Sometimes the first plan is wrong.
Maybe the API PR is too large. Maybe two tiny branches should be one. Maybe a layer belongs lower in the dependency chain.
Open the interactive editor:
gh stack modify
The interface can:
- drop a branch from the stack
- fold one branch into an adjacent branch
- insert a branch
- reorder branches
- rename branches
- preview and undo changes before applying them
The working tree must be clean, and the stack must have a linear history.
After applying the change, update GitHub:
gh stack submit
Restructuring is powerful, but use it early. Reordering layers after detailed reviews have started creates unnecessary work for reviewers.
Use stacks with AI coding agents
GitHub publishes an agent skill for gh-stack:
gh skill install github/gh-stack
You can also install it with the Skills CLI:
npx skills add github/gh-stack
The skill teaches a compatible coding agent how to plan layers, create branches, move through the stack, and rebase after mid-stack changes.
This is a good fit for agent work. An agent can produce a large diff quickly, but a human reviewer still needs understandable boundaries.
The hard part is not asking the agent to “split this into 5 PRs.” The hard part is choosing a dependency order where every layer tells a coherent story.
Review that plan before the agent starts moving commits.
How to design a good stack
A good stack is not one PR per commit.
Create a new layer when the concern, dependency, or reviewer changes.
Good boundaries include:
- schema before code that reads the schema
- shared types before their consumers
- core logic before delivery code
- API before interface
- implementation before integration tests for the whole feature
- production code before documentation, when docs depend on the final behavior
Keep one effort in one stack. A search UI and an unrelated billing fix should not share a chain.
I aim for every layer to answer one sentence:
This PR adds ___ so the next layer can ___.
If that sentence needs 3 “and” clauses, the layer is probably too broad.
Also avoid very deep stacks. Small PRs help review, but every layer adds CI runs, branches, rebases, and review state. Three clear PRs are usually better than 12 microscopic ones.
When not to use a stack
Use a regular pull request when the change is already small and independent.
Do not create a stack when:
- every branch can target
mainindependently - the change takes less time to review than the stack takes to explain
- you need branches across different forks, which GitHub Stacked PRs does not support
- your team is not comfortable with rebasing rewritten branches
- the feature cannot be divided into coherent layers
Stacks are most useful for dependent work that is too large for one review but too connected for independent PRs.
The commands I would remember
You do not need to memorize the entire CLI.
These commands cover the normal workflow:
gh stack init user-model # start the first layer
gh stack add search-api # add a layer on top
gh stack submit # push and create/update PRs
gh stack view # inspect the stack
gh stack up # move away from trunk
gh stack down # move toward trunk
gh stack rebase # cascade changes through the stack
gh stack push # push every branch
gh stack sync # fetch, rebase, push, and sync state
gh stack modify # restructure the stack
The mental model matters more than the commands:
foundations at the bottom
dependencies above them
review from bottom to top
change code in the layer where it belongs
rebase everything above that layer
Once that feels natural, gh stack removes most of the branch bookkeeping.
You can find the latest commands and preview status in the official GitHub Stacked PRs documentation and the github/gh-stack repository.
Related posts about git: