Develop with Git for long enough and you will inevitably run into the question of how to branch. GitFlow, GitHub Flow, Feature Branch. There are plenty of schools of thought, and among them is something called Trunk-Based Development.
When I first heard the term, I assumed it simply meant committing straight to main. Look into it, though, and that turns out to be beside the point. The essence is integrating code into a shared branch in the smallest possible units, as often as possible. And that idea is inseparable from CI/CD.
To put it in terms of a tree, it's a way of developing where you don't let the branches growing off the trunk run wild, but prune them diligently. What follows looks at how this approach spread, how it differs from GitFlow, and companies that actually use it.
Trunk and Branches
Trunk-Based Development is a method of developing around a shared main branch, the Trunk. In Git, themain branch usually plays that role.
The name comes from the trunk directory used in Subversion (SVN). SVN repositories were commonly split into three: trunk/ for the central line of development, branches/ for divergent work, and tags/ for marking releases. Git changed how branches are handled, but the habit of calling the central line of development the Trunk carried over.
What this approach avoids is developing far away from the Trunk for long stretches. You cut feature/login and merge it, cut feature/profile and merge it, cut feature/settings and merge that too. Creating Feature Branches is fine in itself. What matters is keeping every branch short-lived and bringing it back to main often.
DORA (DevOps Research and Assessment) lists four characteristics of Trunk-Based Development. Integrate branch changes into the Trunk at least once a day. Avoid long-lived development branches. Keep the number of active branches small. And avoid code freezes and long integration phases. (DORA - Trunk-based development)
In short, it is not development without branches. It is development that doesn't let branches stay apart for long.
Integration as the Hard Part
To understand why this approach came about, you have to go back to the problem of "integration" in software development.
These days, having several people use Git, review through Pull Requests, and test in CI is a given. It wasn't always so. In large projects of the past, it was not unusual for each team to spend a long time building its own features and then integrate everything at the end.
Say three teams each spend three months developing the same system. Each team finishes its work, and when they finally integrate, a flood of inconsistencies pours out. Then comes round after round of fixes and retesting before they limp to a release. It's like saving a whole year of housecleaning for New Year's Eve.
A typical inconsistency looks like this. Team A had changed the API's response format.
// Team Aが変更したレスポンス
type User = {
id: string;
displayName: string;
};
Meanwhile, Team B had written its frontend against the old format.
// Team Bの実装
function getUserName(user: User) {
return user.name;
}
Each works fine in its own environment. Integrate them, and it breaks. Of course, this is a simplified example; in reality inconsistencies lurk everywhere, in database schemas, library dependencies, and shared interfaces. And with weeks or months of changes piled up, just tracking down which one is the culprit is an ordeal.
In his Continuous Integration article published in 2000, Martin Fowler explained the importance of integrating code daily while repeatedly running automated builds and tests. (Continuous Integration (2000 version)) Rather than doing a big integration after development ends, you integrate little by little during development and find problems early. That's the aim.
CI Tools and Continuous Integration Are Different Things
This is where CI (Continuous Integration) comes in. Hear "CI" and many people picture GitHub Actions or Jenkins. A configuration like this, for example.
name: CI
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
Open a Pull Request and the tests and build run automatically. This kind of setup is often what people call CI. Strictly speaking, though, it's just part of the tooling for achieving CI.
The "Integration" in CI means exactly that. Continuous Integration in its original sense refers to the development practice of continuously integrating changes from multiple developers and confirming that the integrated result works.
Say you develop on a branch calledfeature/a for two weeks. CI on that Feature Branch may have passed every single day. Even so, for two weeks it was never once mixed with anyone else's changes. You were testing continuously, but you can hardly say you were integrating continuously.
Fowler also notes that running a CI tool on a Feature Branch does not by itself amount to Continuous Integration in the original sense. (Continuous Integration Certification) The difference is bigger than it looks. Adopting a CI tool and practicing Continuous Integration are not the same thing. And Trunk-Based Development is the go-to branching strategy for achieving Continuous Integration in that original sense.
The two are closely related, but they sort out like this.
| Concept | Main role |
|---|---|
| Trunk-Based Development | Integrate code into a shared branch at short intervals |
| Continuous Integration | Integrate code frequently and verify it with automated builds and tests |
| CI tools | Automatically run processes such as builds and tests |
Trunk-Based Development is one of the key practices that make CI work, and CI tools are the instruments that support putting it into practice.
GitFlow, a Tree with Many Limbs
Comparing with GitFlow makes the difference in branching strategy clear. GitFlow uses the following branches by role.
| Branch | Purpose |
|---|---|
| main | Code released to production |
| develop | Integrates code under development |
| feature/* | Development of individual features |
| release/* | Release preparation |
| hotfix/* | Emergency fixes to production |
develop into feature/* and returned there, and once a release is ready it goes through release/* into main. Because branches separate code under development from code already released, it's handy when you want tight control over what goes into a release.
On the other hand, maintaining several branches for a long time makes integration tangled. If production needs an emergency fix, fixing main isn't enough; the same fix has to go into develop as well. If features keep landing in develop after a release branch is cut, different changes accumulate on each limb. That isn't bad in itself. But the more lines of development there are, the more there is to look after.
Trunk-Based Development basically consolidates the center of development into one line. You sprout short branches like feature/a, feature/b, and feature/c, and bring them right back to main. Where GitFlow leans toward managing the state of development and releases through branches, Trunk-Based Development emphasizes keeping divergence in the lines of development to a minimum.
That said, Trunk-Based Development does sometimes use Release Branches. The understanding that "Trunk-Based Development forbids Release Branches" is not correct.
Splitting a Notification Feature into Pieces
Let's think it through with a concrete example. Say we're adding a notification feature to a web application. The work is adding a notifications table, an API to fetch notifications, a notification list UI, a way to mark them as read, and tests.
Build it all on a single Feature Branch, and you design the DB on day 1, write the API on day 2, the UI on day 3, read handling on day 4, tests on day 5, and only merge intomain on day 6. Until it's finished, every change lives on the branch. If someone changes the API or authentication in the meantime, the final integration quickly turns into a chore.
So how would Trunk-Based Development do it?
On day 1, add the notifications table in a way that doesn't affect existing features.
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
message TEXT NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL
);
At this stage, users can't see anything yet. Next, add the API that fetches notifications.
@Get('/notifications')
async getNotifications(
@CurrentUser() user: User,
) {
return this.notificationService.findByUserId(
user.id,
);
}
Once the API tests pass, merge into main. The feature as a whole is unfinished, but the changes you added work correctly on their own. In practice you would also need to think about authorization, pagination, and index design, but I've left those out here.
On day 2, add the notification list UI.
function NotificationList() {
const { data } = useNotifications();
return (
<ul>
{data?.map((notification) => (
<li key={notification.id}>
{notification.message}
</li>
))}
</ul>
);
}
Don't add a way to reach the screen yet; just test the component on its own and integrate into main. On day 3, add the API and UI for marking as read, write the tests, and integrate again.
On day 4, you finally add the entry point to the notification screen.
function Header() {
return (
<header>
<Logo />
<NotificationButton />
</header>
);
}
Only now can users actually use the notification feature. You split a large feature into small changes and integrate them step by step.
The knack lies in finding units that can be integrated safely even when the whole feature isn't finished. That's less about how to operate Git and more about how to divide and design the feature.
Etiquette for Putting Unfinished Code into the Trunk
Naturally, a question comes up here. Is it really okay to put an unfinished feature intomain? If you're building a payment feature that takes weeks, half-built code could end up deployed to production.
This is where the idea of separating Deploy from Release comes in. Deploy means placing code in the runtime environment; Release means making a feature available to users. The two usually happen at once, but they don't have to.
The classic tool for this is the Feature Flag.
function PaymentPage() {
const enabled = useFeatureFlag('new-payment');
if (enabled) {
return <NewPaymentPage />;
}
return <CurrentPaymentPage />;
}
Even if the new payment code is in main and deployed to production, users see the existing screen as long as the new-payment flag is false. Once it's finished, flip the flag to true. You can also release it to internal users first, then widen it step by step to 1%, 10%, and 100%.
A disabled flag doesn't make unfinished code harmless, though. Database schema changes and work done at application startup take effect regardless of any flag. Hiding UI on the frontend is not access control for the API. Even with flags, the premise remains that you only integrate changes that work correctly.
Feature Flags aren't free, either. With five flags, each with two states, ON and OFF, there are in theory 32 combinations. With ten, 1,024. Dutifully try them all and the next major version will be out before your tests finish. Of course you don't need to test every combination, but the more branches in the logic, the more complex both tests and code become. Forget to delete a flag that has served its purpose, and an unused conditional squats in your codebase indefinitely.
Fowler, too, says Feature Flags are useful but that you should first consider whether the feature can be split small enough to release safely. (Feature Flag - Martin Fowler) Adopting Trunk-Based Development doesn't mean sprouting a flag on every feature.
The Foundation for CD
Trunk-Based Development is closely tied not only to CI but to CD. CD here has two meanings: Continuous Delivery and Continuous Deployment.
Continuous Delivery is the idea of keeping software in a state where it can be released to production at any time. You integrate changes intomain, pass automated tests, build, verify in a staging environment, and keep it releasable. The production deploy itself can be a human decision. What matters is maintaining a state where releasing doesn't require long stretches of integration or fixing.
Continuous Deployment, on the other hand, automatically deploys changes that pass the required automated checks straight to production. This is where Trunk-Based Development pays off. If several developers integrate big batches of changes every two weeks, automatically pushing each batch to production is risky. Conversely, if changes are small and integrated often, it's easier to keep down the amount of change in any single deploy. And when something goes wrong, a narrow scope of change makes the cause easier to trace.
Of course, adopting Trunk-Based Development doesn't automatically hand you Continuous Deployment. You also need automated tests, deployment automation, monitoring, and rollback. Even so, the idea of frequently integrating safe changes forms the foundation that supports Continuous Delivery and Continuous Deployment. DORA also lists Trunk-Based Development as one of the technical practices that support Continuous Delivery. (DORA - Continuous Delivery)
The Cases of Meta and Microsoft
Trunk-Based Development is no armchair theory. Large development organizations like Meta and Microsoft have published cases of adopting it.
In a 2018 engineering blog post, Meta describes a development model in which developers' changes are integrated into a shared Trunk so that other developers can use the latest code right away. At the same time, in a large organization with a high volume of change, keeping problematic changes out of the Trunk becomes crucial. So Meta built a system that predicts which tests are likely to be affected by a change and runs those. (Predictive test selection to ensure reliable code changes - Engineering at Meta)
In 2017, Meta also published a change in how Facebook's web service was released. Previously, changes were pulled frommaster into a Release Branch and released several times a day. But as development grew, more than 1,000 changes a day were landing in master, and the burden of release management became heavy. So they changed the approach gradually starting in 2016. By April 2017, the entire fleet of production web servers had moved to deploying directly from master. (Rapid release at massive scale - Engineering at Meta)
This case shows that it isn't simply a matter of reducing branches. It has to be considered together with the test and deployment infrastructure that can handle a large volume of change safely.
Microsoft has also published a development approach based on Trunk-Based Development. Developers cut short-lived Topic Branches and integrate intomain through Pull Requests. At release time, on the other hand, they cut Release Branches such as release/129 and release/130. The line of integration is consolidated in main, while release timing is managed separately. The public documentation also touches on development at a scale handling more than 200 Pull Requests, and on cases where hundreds of CI builds run per day. (How Microsoft develops with DevOps - Microsoft Learn)
This case shows that Trunk-Based Development and Release Branches aren't necessarily at odds. On top of that, Microsoft uses Feature Flags to separate deployment from making features available to users. I find it interesting that they think about how development is integrated and how releases are managed as separate questions.
What Pruning Buys You
With all that in mind, let me lay out the benefits.
First, it's easier to keep merge conflicts small. A branch that has been apart for a long time accumulates a pile of changes, and bringing it back tomain means fixing not just conflicts but semantic inconsistencies as well. Integrate small changes often, and you catch problems early. That said, conflicts don't vanish entirely.
Second, it's easier to pin down the cause of a problem. If tests fail after merging a Pull Request that touched 100 files, finding the culprit is hard work. With a change of a few files, the area to look at narrows considerably. Reverting just the problematic change is easy, too. Granted, if there are destructive database changes or side effects on external systems, a Git revert alone may not get you back.
Third, it lightens the load of code review. Personally, I think this is the one that matters most. Between a 2,000-line change and a 100-line change, even if line count alone doesn't decide difficulty, the latter is generally easier to follow. Small Pull Requests are also easier for reviewers to make time for. But under a setup where every review takes days, keeping branches short-lived is out of the question. The review process itself needs rethinking.
Fourth, it's easier to raise release frequency. If changes land inmain routinely, you don't need to merge a mountain of branches before a release. With automated tests and deployment automation in place, you can keep delivering small changes to production. Releases may stop being a special ceremony.
Finally, it makes development with many people easier. Since everyone works from the latest Trunk, other people's changes are picked up early. Even when a shared API changes or a refactoring happens, it's easier to avoid building on top of stale code for weeks. Of course, that assumes testing and communication that can keep up with frequent change.
What Pruning Costs You
There are many benefits, but adopting it won't necessarily make development faster.
First, dependence on automated tests grows. Keepingmain healthy at all times requires solid automated tests. Integrate frequently while tests are thin, and bugs slip into main more easily. Flaky tests that fail often are a headache too. You can no longer tell whether a failure is a real bug or the test environment acting up.
Second, slow CI clogs development. Say CI takes 30 minutes for every Pull Request. Integrate five times a day, and by simple math that's 150 minutes of waiting on CI. You can do other work in the meantime. But if you can't merge until CI finishes, or have to rerun it with every fix, the wait quickly becomes a bottleneck. That's why speeding up tests, running them in parallel, and selecting tests by scope of impact become important. Meta's test selection system mentioned earlier is one answer to this problem.
Third, it takes design skill to split large features. Truth be told, this may be the hardest part. Migrating to a new authentication scheme or doing a large-scale database refactoring can't always be conveniently carved into changes that finish in a few hours. In those cases, you need a design where the old and new mechanisms coexist for a while.
For example, to rename a column, you don't just drop the old one outright. First, add the new column. Next, make the application handle both old and new. Then migrate the existing data, switch over to the new column, and finally drop the old one. This is close to the migration pattern commonly called Expand and Contract. Splitting things small makes migration safer, but design and implementation get more involved accordingly.
Fourth, you may need to manage Feature Flags. Using flags to integrate unfinished features safely adds configuration and test targets. Forget to delete them, and unused code lingers for a long time. If you introduce flags, the operation has to cover not just creating them but removing them.
And fifth, it takes cooperation from the whole team. Individual developers keeping their branches short isn't enough on its own. If reviews take three days, integration lags no matter how small the changes are. If CI breaks and nobody fixes it, everyone else's integration stalls too. Trunk-Based Development isn't about one person's Git habits; it's about the team's entire development process.
Where It Fits and Where It Doesn't
Given all this, it seems to suit products like web applications and SaaS where features are added continuously. Automated tests and CI/CD are in place, releases can go out in small units, and reviews turn around quickly. When an incident occurs, you can back out with a revert or rollback. In an environment like that, Trunk-Based Development should settle in without much fuss.
On the other hand, it takes some ingenuity where test automation is hard, or where every release requires lengthy certification and verification. The same goes for products that maintain multiple versions for a long time, projects with heavy coordination across external vendors or multiple organizations, and setups where developers can't integrate code frequently.
That doesn't mean such environments can't adopt it. Even for a product that maintains multiple versions, you can develop around the Trunk and maintain only released versions on Release Branches. The Microsoft case above serves as a reference for that.
No Need to Leap All at Once
Even when actually adopting it, I don't think every branch needs to become short-lived from day one. A team that keeps Feature Branches around for a week or so can start by making Pull Requests smaller.
As for the order, first shrink the scope of Pull Requests so that Feature Branches can be merged within a few days. Next, cut CI run times and shore up automated tests. Then increase designs that allow features to be integrated in stages, and bring in Feature Flags if needed. In that way, work gradually toward integrating at least once a day. Rather than abolishing GitFlow overnight, gradually reducing long-lived branch separation is often the more realistic path.
Also, adopting Trunk-Based Development doesn't mean getting rid of Pull Requests or code review. You can use GitHub's Branch Protection so that only changes with passing CI and an approved review make it intomain. In the end, which branching strategy you claim to follow doesn't matter. What matters is how often you are actually integrating changes.
Is GitFlow Wrong?
I've focused on the benefits of Trunk-Based Development so far, but that doesn't make GitFlow wrong.
GitFlow has the strength of clearly separating in-progress changes from release targets by branch. For products with fixed release dates, or when multiple versions have to be managed, separation by branch can be useful. For web services that improve continuously, on the other hand, long-lived branch separation can be what slows development down.
Side by side, they look like this.
| Aspect | GitFlow | Trunk-Based Development |
|---|---|---|
| Center of development | Multiple role-specific branches such as develop | A single Trunk such as main |
| Feature Branch | Often kept until the feature is complete | Kept short-lived |
| Integration frequency | Depends on branch operation | Goal of at least daily |
| Release management | Uses Release Branches | Uses Release Branches as needed |
| Unfinished features | Easy to isolate in branches | Managed through small changes, Feature Flags, etc. |
| Fit with CI/CD | Depends on how it's operated | Works well with high-frequency CI/CD |
| Main challenges | Cost of integrating and syncing between branches | Test infrastructure, splitting changes, review speed |
main often under GitHub Flow, it's fair to say you're practicing the ideas of Trunk-Based Development. These methods can't be neatly separated by name alone.
That's what I found while looking into Trunk-Based Development. At first I thought it was just about having fewer branches, but tracing its background, it turned out to be deeply rooted in the idea of Continuous Integration. Develop with several people, and changes have to be integrated sooner or later. The longer that integration is put off, the more changes pile up and the more tangled the problems get. So you keep changes small and integrate often. That is at the heart of this approach.
Putting it into practice takes a range of mechanisms: automated tests, faster CI, Feature Flags, and designs that can be integrated in stages. Above all, how to break a large feature into small changes is not a question of Git knowledge but of software design itself. The goal of Trunk-Based Development is not to reduce the number of branches. It is to lighten the burden of integration and keep software in a state where it can keep improving. Before agonizing over GitFlow versus Trunk-Based Development, ask how much integration burden your own development carries and what it would take to reduce it. Whether a tree's branches are well shaped comes down, in the end, to whether the tree can keep growing.
References
- DORA - Trunk-based development
- DORA - Continuous delivery
- DORA - Working in small batches
- Martin Fowler - Continuous Integration
- Martin Fowler - Continuous Integration (2000 version)
- Martin Fowler - Continuous Integration Certification
- Martin Fowler - Patterns for Managing Source Code Branches
- Martin Fowler - Feature Flag
- Trunk Based Development
- Engineering at Meta - Predictive test selection to ensure reliable code changes
- Engineering at Meta - Rapid release at massive scale
- Microsoft Learn - How Microsoft develops with DevOps