Splitting pull requests sounds like a chore you save for a quiet day. I see it differently: the size of a pull request often decides the quality of a review more than any checklist. If someone hands you 1,400 changed lines, you skim, spot two typos and click “Approve”. Give the same person 120 lines and they understand what is going on and ask the one question that keeps a bug out of production.
In my article on feedback in code reviews I only touched on small pull requests briefly. This post is about the craft behind them: how do you slice a larger change so that every piece makes sense on its own, main keeps working, and you still don’t spend days waiting for reviews? You get slicing patterns, a complete Git workflow for stacked branches and a small feature flag example in TypeScript that you can try right away.
In short
Large changes get better when you break them into several small, self-contained pull requests that build on each other. Clear slicing patterns, stacked branches and feature flags for unfinished work make this possible. The biggest obstacle is not Git but waiting time: without fast reviews in the team, the stack piles up.
What you will learn in this article
- How to tell that a pull request is too big
- Five slicing patterns for breaking changes down sensibly
- How to maintain stacked branches with
git rebase --update-refs, even after squash merges - How to merge unfinished features safely behind a feature flag with an expiry date
- What a team should agree on so small PRs don’t end up in a queue
Why splitting pull requests pays off
The obvious reason is the review itself. A small pull request fits into the gap between two meetings, while a big one needs a free afternoon, and hardly anyone has that. As a result, big PRs sit around, collect conflicts with other branches and end up being waved through under time pressure. Google’s public code review guide lists more benefits: fewer bugs because the impact is easier to reason about, less wasted work if the direction turns out to be wrong, and simpler rollbacks.
For me there is a human side as well. Criticism of 100 lines feels different from criticism of three weeks of work. When you deliver in small pieces, you get feedback earlier and rarely have to throw away large chunks. That noticeably lowers the pressure on both sides of the review.
By now there is a third reason: AI agents. An agent writes a diff in ten minutes that takes a human an hour to review. In its chapter on working in small batches, the DORA team explicitly points out that AI tools tend to produce oversized changes. When writing gets cheap, reading becomes the bottleneck, and that is exactly where small pull requests help.
How big is too big?
There is no fixed limit. Google’s guide calls roughly 100 lines usually reasonable and roughly 1,000 lines usually too large. The number of files counts as well: 200 lines in one file are easier to read than 200 lines spread across 50 files. You can be more generous with deleted files and with changes produced by a trusted, automated refactoring tool.
I prefer a question to a number: can someone on the team review this pull request with focus in 15 minutes and explain afterwards what it does? If not, it is worth cutting. Git shows you the current size compared to main directly:
git diff --shortstat main...HEAD
git diff --stat main...HEAD
The three dots matter. main...HEAD compares against the point where your branch forked, so it shows only your own changes, not the ones that have landed on main in the meantime.
Slicing patterns for splitting pull requests
A good pull request does exactly one thing, includes the matching tests and leaves the system in a working state. It may be part of a feature, but it has to make sense on its own. The following patterns help me find that cut. They also combine well.
Clean up first, then change
The most effective pattern is also the least spectacular one. If a feature requires you to rename a class, extract a function or move files, do that in a separate pull request without new logic. Reviewers can check a pure refactoring quickly because there is only one question: does everything behave as before? If you mix the renaming with the bug fix, the reviewer has to hunt for the one meaningful line among 300 mechanical ones. Small cleanups such as renaming a local variable can of course stay in the feature PR.
Write the tests first
If the code you want to change has no tests, write them first and ship them separately. This pays off twice. The test PR carries no risk because it doesn’t change behavior. In addition, it protects your later refactoring: if the tests still pass afterwards, you haven’t broken anything. The same applies to new test helpers or fixtures that several later PRs will need.
Slice by layer
A horizontal slice separates data model, business logic, API and user interface. For example, the new table with its repository comes first, then the service, then the endpoint. The advantage: each layer can be reviewed by the person who knows it best. However, make sure every new function is used or at least tested somewhere. An API without callers is hard to judge, which is why Google’s guide recommends submitting a new interface together with a usage of it.
Slice by feature
A vertical slice cuts through all layers but delivers only a small part of the feature. For a wishlist, that could be “save a book” first, then “show the list”, later “remove a book”. Each slice is a small, complete piece that can be tested from a user’s point of view. Vertical slices work well when several people want to work in parallel.
Keep configuration separate from code
Code that makes a feature possible and the configuration that switches it on belong in different pull requests. That way you can merge the code early and treat switching it on as a separate, tiny step. If in doubt, a rollback then only means flipping a switch back. I’ll show what that looks like further down with a feature flag.
For larger rebuilds, “branch by abstraction” helps as well: you put an abstraction in front of the old code, build the new implementation behind it step by step and only switch over at the end. Every intermediate step lands on main without a long-lived feature branch.
An example from a bookstore
Let’s take a made-up online bookstore. Customers should be able to put books on a wishlist. As a single pull request, that quickly becomes 600 lines in 25 files. Broken down, the work looks like this:
One feature, five pull requests: every step is small and keeps main working.
| PR | Content | What the reviewer checks |
|---|---|---|
| #1 Refactoring | Split the book service into smaller functions, no new logic | Does everything behave as before? |
| #2 Tests | Missing tests for the book service | Do the tests cover the existing behavior? |
| #3 Data model | Table and repository for wishlists, with tests | Does the model fit, is data access clean? |
| #4 API behind flag | Endpoints GET/POST /wishlist, flag off by default |
Is the logic right, and is it invisible without the flag? |
| #5 Release | Switch the flag on, update the docs | Is the timing right? |
Each of these pull requests can be understood in a few minutes. The more interesting question is how you keep working on #3 while #1 and #2 are still in review. That is what stacked branches are for.
Stacked pull requests with Git, step by step
“Stacked pull requests” means that each branch forks not from main but from the previous branch. As a result, each pull request shows only its own change. Anyone who has tried this by hand knows the problem: when the bottom branch changes after a review, you have to rebase every branch above it one by one. Since Git 2.38, the --update-refs option takes care of that.
Prerequisites
- Git 2.38 or later (check with
git --version) - A GitHub repository with write access
- Optionally the GitHub CLI
gh, signed in withgh auth login - A team that knows stacked PRs are merged from the bottom up
Step 1: Create the branches
Start on an up-to-date main and create a branch for each slice, each one on top of the previous. A number in the name helps everyone see the order.
git switch main
git pull
git switch -c wishlist/1-refactoring
# ... do the refactoring, commit ...
git switch -c wishlist/2-tests
# ... write the tests, commit ...
git switch -c wishlist/3-data-model
# ... build the data model, commit ...
Step 2: Open the pull requests with the right base
The base of each pull request is what matters. The first one targets main, every further one targets the branch below it. With the GitHub CLI it looks like this:
git push -u origin wishlist/1-refactoring wishlist/2-tests wishlist/3-data-model
gh pr create --base main --head wishlist/1-refactoring \
--title "Wishlist 1/5: tidy up the book service" --body "Pure refactoring, no new logic."
gh pr create --base wishlist/1-refactoring --head wishlist/2-tests \
--title "Wishlist 2/5: tests for the book service" --body "Builds on #1."
gh pr create --base wishlist/2-tests --head wishlist/3-data-model --draft \
--title "Wishlist 3/5: data model" --body "Builds on #2. Still a draft."
Expected result: three pull requests, and each one shows only the changes of its own branch in the “Files changed” tab. If the second PR also shows the refactoring from the first, the base is wrong. You can fix that on GitHub via “Edit” next to the title or with gh pr edit <number> --base <branch>.
Step 3: Carry review changes up from the bottom branch
Now comes the part that used to hurt. The review of #1 has a comment, and you commit the fix to wishlist/1-refactoring. To get the fix into the two branches above, you rebase the top branch and let Git move the intermediate ones along:
git switch wishlist/1-refactoring
# ... apply the review fix ...
git commit -am "Review: clarify function names"
git switch wishlist/3-data-model
git rebase --update-refs wishlist/1-refactoring
git push --force-with-lease origin wishlist/2-tests wishlist/3-data-model
At the end, Git reports “Updated the following refs with --update-refs” and lists wishlist/2-tests. Without the option, the middle branch would keep pointing at the old commits. When pushing, --force-with-lease is the safe variant of --force: Git refuses the push if someone else has pushed to the branch in the meantime.
🎬 Stacked branches with git rebase --update-refs – this animated diagram only runs on my site: watch it on sascha-ms.de
If you use stacked branches regularly, turn the option on permanently. Then you don’t have to remember it:
git config --global rebase.updateRefs true
Step 4: Rebase the stack after a squash merge
Many teams merge with “Squash and merge”. In that case #1 lands on main as a single new commit, and the original commits of the branch don’t exist on main. A normal rebase would therefore try to apply them again and cause conflicts. The solution is --onto: you tell Git that everything after the old branch state belongs on top of the new main.
git switch main
git pull
git switch wishlist/3-data-model
git rebase --update-refs --onto main wishlist/1-refactoring
git push --force-with-lease origin wishlist/2-tests wishlist/3-data-model
gh pr edit wishlist/2-tests --base main
Important: only delete the local branch wishlist/1-refactoring after this rebase, because it marks the boundary for Git. Afterwards, git log --oneline main..wishlist/3-data-model shows only the commits of #2 and #3. Then switch the base of #2 to main, unless GitHub has already done that for you.
Common mistakes
- Merging from the top: If you merge #3 before #1, all three changes end up in someone else’s branch. Stacked PRs are always merged from the bottom up.
- Stacks that are too tall: With four or five open levels, every fix at the bottom gets expensive. Wait for a review rather than letting the stack keep growing.
- Rebasing mid-review without a note: After a force push, reviewers easily lose track. A short comment such as “rebased, only fix X is new” helps.
-
Forgetting worktrees: Branches that are currently checked out in another Git worktree are not updated by
--update-refs.
Merge unfinished work safely with feature flags
The fourth PR of the wishlist contains endpoints that nobody should see yet. This is where a feature flag comes in: a switch in the code that shows or hides a feature at runtime. Pete Hodgson’s article on martinfowler.com distinguishes several kinds. For small pull requests you almost always need a “release toggle”: a short-lived switch meant to hide unfinished code on main that disappears again afterwards.
To get started, you don’t need a flag service. An environment variable and a few lines of TypeScript are enough. The following example runs with Node.js 22.18 or later directly, without a build step, because Node strips the types when it executes the code.
Step 1: Create the project
mkdir wishlist-flags && cd wishlist-flags
npm init -y
npm pkg set type=module
npm install --save-dev typescript @types/node
mkdir src test
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["src", "test"]
}
Step 2: Define the flag with an expiry date
Every flag gets a default value and a date by which it must be removed again. That date is not decoration, as the test shows in a moment.
// src/feature-flags.ts
export type FlagName = 'wishlist';
interface FlagDefinition {
defaultValue: boolean;
removeBy: string; // Latest date by which the flag must be gone from the code
}
export const flags: Record<FlagName, FlagDefinition> = {
wishlist: { defaultValue: false, removeBy: '2026-12-31' },
};
export function isEnabled(
flag: FlagName,
env: Record<string, string | undefined> = process.env,
): boolean {
const raw = env[`FEATURE_${flag.toUpperCase()}`];
if (raw === undefined) {
return flags[flag].defaultValue;
}
return raw === 'true' || raw === '1';
}
Step 3: Put the new feature behind the flag
// src/routes.ts
import { isEnabled } from './feature-flags.ts';
export function buildRoutes(env?: Record<string, string | undefined>): string[] {
const routes = ['GET /books', 'GET /books/{id}'];
// Part 4 of 5: the wishlist is already merged but still invisible.
if (isEnabled('wishlist', env)) {
routes.push('GET /wishlist', 'POST /wishlist');
}
return routes;
}
Step 4: Tests that report a forgotten flag
The third test is a small time bomb in the good sense. Hodgson describes the idea of letting expired toggles fail the build. Once the date has passed, the build turns red and nobody can forget the cleanup.
// test/feature-flags.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { flags, isEnabled } from '../src/feature-flags.ts';
import { buildRoutes } from '../src/routes.ts';
test('wishlist is off by default', () => {
assert.equal(isEnabled('wishlist', {}), false);
assert.deepEqual(buildRoutes({}), ['GET /books', 'GET /books/{id}']);
});
test('wishlist can be switched on with an environment variable', () => {
const env = { FEATURE_WISHLIST: 'true' };
assert.equal(isEnabled('wishlist', env), true);
assert.ok(buildRoutes(env).includes('POST /wishlist'));
});
test('no release flag lives longer than planned', () => {
const today = new Date().toISOString().slice(0, 10);
for (const [name, def] of Object.entries(flags)) {
assert.ok(today <= def.removeBy, `Flag "${name}" should have been removed by ${def.removeBy}`);
}
});
npx tsc --noEmit
node --test "test/**/*.test.ts"
FEATURE_WISHLIST=true node -e "import('./src/routes.ts').then(m => console.log(m.buildRoutes()))"
Expected result: tsc reports nothing, all three tests pass, and the last command prints four routes, including POST /wishlist. Without the environment variable, you only get the two book routes.
Two notes from practice. First, a flag doesn’t protect data. If the hidden code needs a database migration, that migration must be harmless even with the flag switched off, for example because it only creates a new table. Second, every flag doubles the paths you have to test. Treat release toggles like debt that accrues interest. Plan their removal right away as a separate ticket, ideally as PR #6.
A pull request that speaks for itself
A small pull request is only easy to review if the context is clear. With stacked PRs, every description should therefore say where the piece fits into the whole. Here is a template I find useful:
## Wishlist 3/5: data model
Part of a series:
1. #101 Tidy up the book service (merged)
2. #102 Tests for the book service (in review)
3. → this PR
4. API behind feature flag (planned)
5. Release (planned)
## What changes?
New table for wishlists and a repository with tests.
No endpoint yet, no visible change yet.
## What should the review focus on?
Key design of the table: is it enough for "all books of a customer"?
The list at the top does half the work. Whoever reviews #3 sees immediately that the endpoints are missing on purpose and doesn’t ask about them. GitHub links the pull request numbers automatically.
Agree as a team on splitting pull requests
In the end, small pull requests are a team decision, not a matter of individual discipline. If a review takes two days, it is perfectly reasonable for the author to put everything into one big PR. Size follows waiting time. As a team lead, I would therefore work on response time first and only then talk about line counts.
A few simple agreements have proven useful. A review is started within half a working day, and a short first look counts. Pull requests above an agreed size need a heads-up beforehand so nobody is caught off guard. Reviews count as real work rather than an interruption, so they show up in planning and stand-ups. Also, anyone may hand a PR back with a request to split it, without that being seen as criticism of the person.
The same applies to AI agents, only more explicitly. An agent doesn’t split its work on its own. So give it the rule: first a pure refactoring, then one commit with tests per step, at most one functional change per branch. How to enforce guardrails like these technically for Claude Code is covered in my post on Claude Code hooks.
One metric helps the conversation in the team, as long as it doesn’t become a target: how long does it take from opening a pull request to the first review comment? When that time goes down, PRs usually get smaller on their own.
My take
I consider splitting pull requests one of the most underrated skills in everyday development. It forces you to think about the order before you write code, and that almost always improves the design too. If you don’t know how to slice a change, you often haven’t fully understood it yet.
I disagree with two common attitudes. The first one goes: “Let’s just add a 200-line limit to CI.” That leads to artificial cuts where one PR contains half a function and the next one the other half. A hard limit doesn’t replace judgment. The second attitude: “Stacked PRs are only for big companies with their own tooling.” With --update-refs and the GitHub CLI you get surprisingly far before you need a specialized tool. The tool is rarely the problem. Making reviews fast as a team is the harder part.
FAQ
How big should a pull request be?
There is no fixed limit. Google’s review guide calls roughly 100 changed lines usually good and roughly 1,000 usually too many. More important than the number is whether someone can understand the change in a focused quarter of an hour.
What are stacked pull requests?
Stacked pull requests build on each other: each branch forks from the previous one, and each PR uses the branch below it as its base. That way each PR shows only its own change, and you can keep working while the lower parts are in review.
How do I update several dependent branches at once?
Run git rebase --update-refs <base> on the top branch. Git rebases it and automatically moves all branches in between along with it. You can turn this on permanently with git config --global rebase.updateRefs true.
Can I merge unfinished code?
Yes, if it sits behind a feature flag that is off by default and main keeps working. Plan the removal of the flag right away, otherwise old switches pile up in the code.
Your next step
Pick the next task where you can see a larger pull request coming, and before writing the first line of code, write down the list of slices, just like the table above. Start with the refactoring PR and try --update-refs as soon as the first review feedback arrives.
I’d like to hear about your experience: where do small pull requests break down for you, in the slicing itself or in the waiting time for reviews? Let me know in the comments.
Sources
- Google Engineering Practices: Small CLs
- Git documentation: git rebase (--update-refs, --onto)
- GitHub Blog: Highlights from Git 2.38
- DORA: Working in small batches
- Pete Hodgson on martinfowler.com: Feature Toggles (aka Feature Flags)
- GitHub Docs: Changing the base branch of a pull request
- GitHub CLI Manual: gh pr create
This post was created with AI assistance.
👉 Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)