Small Pull Requests Are a Technical Strategy, Not a Team Preference
Learn how small pull requests improve review, feedback, rollback safety, and architecture—and how to split real features without breaking the system.
2026-09-04 18:49:58 - Mohamad Abuzaid
A “complete feature” pull request feels efficient.
One branch. One ticket. One review. Database migration, API, business logic, UI, tests, and cleanup all arrive together. The author gets to present a finished story instead of explaining several intermediate steps.
The problem is that the first serious feedback also arrives together.
By then, a questionable API shape may already be used by the UI. A risky migration may be buried under generated files. A reviewer who disagrees with the architectural direction must now ask for changes across the entire stack. The feature may be complete from the author’s perspective, but it is expensive for the team to understand, change, merge, release, or reverse.
Small pull requests are not a matter of reviewer taste. They are a way to control technical risk.
DORA’s guidance on working in small batches connects smaller units of work with faster feedback, easier remediation, and safer AI-assisted delivery. Google’s engineering practices make the same idea concrete at code-review level: a good change is focused, includes its related tests, gives the reviewer enough context, and leaves the system working after it is merged.
That last condition matters most. “Small” is not a line-count target. It is a design constraint.
The false economy of the complete-feature PR
A large pull request often optimizes for the person writing the code while pushing cost onto everyone after them.
The author keeps all the context in one branch and avoids thinking about intermediate compatibility. The reviewer receives the opposite experience: many decisions, several kinds of risk, and no obvious place to start.
That creates predictable failure modes:
- Review begins late because finding a large uninterrupted block of time is difficult.
- Architectural feedback arrives after dependent code has already been written.
- Important behavior is harder to distinguish from formatting, movement, and generated noise.
- A defect is harder to isolate because the change has several plausible causes.
- Rolling back one unsafe behavior may also remove unrelated, working parts of the feature.
- Merge conflicts grow while the branch waits for review and catches up with main.
None of this means that a 40-line change is automatically safe or that a 600-line change is automatically bad. A tiny authorization mistake can be dangerous. A large generated migration can be mechanically simple.
The useful question is not “How many lines changed?” It is “How many independent ideas must a reviewer hold in their head at once?”
What makes a pull request genuinely self-contained?
I use one practical definition:
A self-contained pull request makes one coherent change, proves what that change does, and leaves the repository safe to merge and deploy.
That usually means the pull request has:
- One review question. A reviewer can describe the decision in one sentence: “Does this validation reject unsupported time zones?” or “Does this refactor preserve the current scheduling behavior?”
- A working post-merge state. The build passes, existing behavior remains available, and production does not depend on a future pull request arriving quickly.
- The relevant evidence. Logic and its focused tests travel together. A UI change includes visual or interaction evidence. A migration explains compatibility and operational risk.
- A coherent rollback boundary. Reverting the change removes the behavior it introduced without accidentally removing three unrelated improvements.
- Explicit scope. The description says what is intentionally not included yet.
Self-contained does not always mean immediately visible to an end user. An optional database field, a new service seam, or a disabled code path can be a complete enabling change when it is independently testable, backward-compatible, and useful to the next slice.
It is also possible to go too small. Adding an unused interface in one pull request and its only implementation in another can make both reviews harder. Google’s guidance explicitly warns against changes so fragmented that their implications are hidden. Small changes still need enough context to be understood.
Slice by behavior, not by the project tree
“Database PR, backend PR, frontend PR” sounds like a plan. Sometimes it is. Sometimes it is only a list of folders.
A better split starts with behavior and risk:
- What can the system safely do after this merge that it could not do before?
- What assumption becomes testable?
- Which compatibility boundary must remain open for older code?
- Which decision deserves feedback before more code depends on it?
- Can this slice be deployed without exposing an incomplete user journey?
Vertical slices are useful when a thin user behavior can cross the stack safely. Horizontal or layer-based slices are useful when they establish a stable contract that lets later work proceed. The label matters less than the post-merge state.
The mistake is splitting mechanically and hoping the pieces become coherent on their own.
A real feature, divided without breaking the system
Imagine an application that sends every weekly digest at a fixed UTC time. We want users to choose an IANA time zone such as Africa/Cairo, and we want the scheduler to send the digest at 09:00 in that local zone.
This touches the database, API, scheduler, settings UI, tests, rollout configuration, and eventually old compatibility code. It could easily become one “complete” pull request.
Here is a safer sequence.
- PR 1 — Scheduling boundary: Add characterization tests and extract the boundary. Existing UTC scheduling tests pass before and after; no user-visible behavior changes.
- PR 2 — Compatible contract: Persist an optional digestTimeZone and round-trip it through the API. Migration, validation, repository, and contract tests prove old records and supported clients keep working.
- PR 3 — Flagged scheduler: Calculate local 09:00 behind a disabled release flag. Tests cover UTC fallback, valid zones, and clock-boundary cases; the new logic is deployed but inactive.
- PR 4 — Settings UI: Enable the control for a small cohort. UI tests and a visible save-and-reload check prove only enabled users can select a zone.
- PR 5 — Rollout and constraint: Observe the rollout, backfill missing values to UTC, and enforce the final rule. Operational checks and migration validation preserve the compatibility fallback until rollout is complete.
- PR 6 — Cleanup: Remove the flag and obsolete UTC-only branch after the focused suite and production checks remain green.
The first pull request is deliberately boring. It separates structural movement from behavioral change. If the scheduler refactor changes an output, the reviewer knows the cause is in that small diff—not mixed with a new API and UI.
The second pull request expands the contract without requiring every caller to change at once. A PostgreSQL migration might begin with:
ALTER TABLE user_preferences
ADD COLUMN digest_time_zone text;
The application treats NULL as the old UTC behavior. New code can write the field; old code can continue without it. This is safe only after checking the real client and serialization contracts—some consumers reject unknown fields, and some deployment systems run old and new application versions at the same time.
The final constraint belongs later, after the data is backfilled and all active application versions can supply the value:
ALTER TABLE user_preferences
ALTER COLUMN digest_time_zone SET NOT NULL;
That command is valid PostgreSQL, but “valid SQL” is not the same as “safe production migration.” Table size, existing data, locks, traffic, database version, and rollback requirements still matter. PostgreSQL’s current ALTER TABLE documentation explains options such as adding some constraints as NOT VALID and validating them separately to reduce the impact on concurrent work.
The important pattern is expand, migrate, then contract. Do not rename a required column, deploy code that only understands the new name, and call the combination atomic unless your deployment environment truly makes it atomic.
Refactoring, schema, APIs, and flags need different slicing tactics
Different kinds of change fail in different ways. They should not all use the same splitting recipe.
Refactoring: separate movement from meaning
If you rename, move, and redesign behavior in one diff, reviewers must reconstruct which lines are mechanical and which lines change the result.
Start with characterization tests when the existing behavior is not already protected. Then move or extract code without changing its public behavior. Only after that should a later pull request introduce the new rule.
For larger replacements, Branch by Abstraction can create a temporary seam between the old and new implementations. The seam has a cost, so remove it when the migration finishes.
Schema changes: design for version overlap
Production rarely switches every process, worker, and client at the same instant. A safe schema sequence assumes old and new code may overlap:
- Add the new structure in a compatible form.
- Deploy code that can read both shapes and writes the intended new shape.
- Backfill or migrate existing data with observable progress.
- Stop old writers and confirm the old path is no longer used.
- Enforce the new constraint and remove obsolete structure.
A code rollback and a data rollback are not the same thing. Reverting application code does not automatically undo a backfill, restore deleted data, or make an old binary compatible with a contracted schema.
APIs: add before you remove
Additive changes are easier to stage than replacements. Introduce a new optional field or endpoint, deploy consumers, observe usage, and remove the old contract only after you know active clients no longer depend on it.
This is not permission to keep every version forever. Compatibility has a carrying cost. Give deprecated paths an owner and a removal condition when you add them.
Feature flags: isolate exposure, then delete the flag
A release flag lets you deploy completed internal slices without exposing an unfinished journey. Test the flag-off path, the flag-on path, and the transition between them. Keep the check near the feature entry point instead of scattering conditionals across the codebase.
Flags are temporary architecture. If there is no cleanup pull request, removal condition, or owner, the “temporary” branch becomes another permanent state the team must understand and test. Martin Fowler’s feature-toggle guidance is useful precisely because it discusses both release flexibility and the complexity created by multiple toggle states.
Stacked pull requests keep the author moving
Small changes do not require an author to wait idle after every review request.
With stacked pull requests, each branch builds on the branch below it:
main
└── pr-1-scheduler-boundary
└── pr-2-time-zone-contract
└── pr-3-flagged-scheduler
└── pr-4-settings-ui
Reviewers see the difference between adjacent layers instead of repeatedly reviewing the whole feature. The stack documents dependency order, and the author can continue working while lower changes are reviewed.
The discipline does not disappear. Each layer still needs a focused purpose, passing checks, and a safe merge state. If PR 2 cannot function without PR 3 landing immediately, the stack has hidden a broken intermediate state rather than solved it.
Stacking also adds overhead: lower-layer changes can ripple upward, reviewers need the dependency order, and rebasing several branches can become tedious. At the time of writing, GitHub documents native stacked pull requests, including CI and bottom-up merging, but the feature is in public preview and its tooling constraints may change. The workflow principle is older than any one platform feature.
Every pull request should carry its own evidence
A reviewer should not have to ask what was tested, which risk matters, or where this change sits in a sequence.
A compact description can be enough:
## Purpose Persist and validate an optional digest time zone. ## Behavior - Existing users continue to receive digests on the UTC schedule. - New clients can save an IANA time-zone identifier. - Unsupported identifiers return a validation error. ## Not in this PR - Scheduler behavior - Settings UI - Backfill and final constraint ## Risk and rollback Additive nullable column. Application rollback keeps the column unused. No data is deleted or transformed. ## Evidence - Migration test - API contract tests for old and new payloads - Repository round-trip test - Focused command: ./gradlew test --tests '*DigestPreferences*' ## Dependency Builds on PR #101. Next: flagged scheduler behavior.
The exact template is not important. The information is.
This extends the idea from my Claude Code and Android CLI workflow: generated code is a proposal; the toolchain and observed behavior are the proof. A small pull request gives that proof a clear boundary.
Useful evidence depends on the change:
- Behavioral logic: focused tests plus the exact command that ran them.
- Refactoring: tests showing public behavior is unchanged and a diff free of unrelated edits.
- Schema: compatibility assumptions, migration rehearsal, lock analysis, and data validation.
- API: request/response examples and contract tests for supported consumers.
- UI: screenshots or recordings for relevant states, plus interaction or accessibility checks.
- Operations: metrics, logs, alerts, flag state, and a concrete reversal path.
Do not paste a wall of green logs into the description. Summarize the result and link to durable evidence. My article on developer portfolios and engineering decisions makes the same distinction: a claim becomes useful when the reader can follow it to an artifact and a reproducible check.
Small changes create architectural pressure
There is a deeper benefit to this practice.
If a feature cannot be introduced without changing the database, API, scheduler, and UI in one synchronized merge, the system may be telling you that its boundaries are too rigid.
The effort to create a small, safe slice encourages useful design properties:
- additive contracts instead of coordinated replacement;
- explicit interfaces between responsibilities;
- business logic that can be tested without launching the whole application;
- configuration at feature entry points;
- migrations that tolerate version overlap;
- observability that can distinguish the old path from the new one.
This does not mean adding an interface around every class. Abstraction has a cost. It means noticing when delivery is difficult because one decision is entangled with five others.
My architecture patterns overview discusses boundaries between responsibilities. Pull-request design is where those boundaries meet daily reality. If the boundary exists only in a diagram and cannot support an incremental change, it is probably not doing much work.
AI makes decomposition more important, not less
Coding agents can produce a complete cross-stack implementation before a human reviewer has seen the first design decision. That increases output. It does not increase review capacity or reduce the cost of a wrong assumption.
Give the agent one behavioral slice, its compatibility rules, explicit out-of-scope items, and the checks that must prove completion. Review that result before asking for the next dependent slice.
Also inspect scope drift. An agent may upgrade dependencies, rename nearby types, reformat files, or “clean up” unrelated code while implementing a small feature. Those changes are not free just because they were generated quickly.
DORA’s current small-batch guidance calls this practice a safety mechanism for AI adoption. I agree with the direction, with one qualification: small batches only help when humans can understand and verify them. Ten opaque pull requests created faster than anyone can review them are still a queue of risk.
When a large change is genuinely hard to avoid
Some diffs are large for reasons that do not map cleanly to review effort:
- generated code or lockfile changes;
- a trusted automated refactor across many files;
- vendored assets or protocol output;
- a broad security fix that must close one vulnerability consistently;
- deletion of an obsolete subsystem;
- a platform migration with a truly atomic external constraint.
Do not pretend those changes are small by distributing the same decision across arbitrary pull requests.
Instead:
- Separate generated or mechanical output from hand-written semantic changes when possible.
- Explain why the change must stay together and get reviewer agreement before opening it.
- Provide a review map: important files, generated areas, invariants, and the order in which to inspect them.
- Strengthen automated checks and rehearse migration and rollback paths.
- Pair on the highest-risk sections instead of asking one reviewer to reconstruct everything asynchronously.
- Keep unrelated cleanup out, even if the main diff is already large.
Large can be justified. Unstructured cannot.
A practical pull-request splitting checklist
Before coding:
- Can I describe the first useful or enabling behavior in one sentence?
- Which decision needs feedback before the rest of the feature depends on it?
- Will old and new application versions overlap?
- Does a refactor or characterization-test change need to land first?
- Can a flag, additive contract, or abstraction create a safe intermediate state?
Before opening each pull request:
- Does it contain one coherent change rather than one directory?
- Does the repository still build, test, and deploy after this merge?
- Are the related tests and evidence included?
- Are generated, mechanical, and behavioral edits distinguishable?
- Are scope, dependencies, risk, and rollback explained?
- Is the next pull request optional for system correctness, even if it is required to finish the feature?
Before merging:
- Did CI run against the correct base and dependency state?
- Can production tolerate the old and new contracts at the same time?
- Is the flag-off or fallback path tested?
- Can this change be reverted without damaging data or removing unrelated behavior?
- Is temporary compatibility code assigned a cleanup condition?
Final thoughts
Small pull requests do more than make reviewers happier. They shorten the distance between a decision and feedback, give failures a smaller search area, make rollback boundaries clearer, and force the architecture to support change in safe stages.
The goal is not to maximize the number of pull requests. It is to minimize the number of unrelated decisions inside each one.
The next time a “complete feature” becomes a huge diff, do not start by asking which files can move to another branch. Ask which behavior can become safe, useful, and provable first.
What is the hardest kind of feature for your team to split without leaving the system in a broken state?