Advanced Workflows
Git Flow vs trunk-based development, their trade-offs, and feature flags as an alternative to long-lived branches.
Branching strategy is a team decision, not just a Git feature
Branching and Merging and Rebasing and History covered the mechanics of branches, merges, and rebases. Neither answers a genuinely important organizational question: what should a team's branches themselves actually represent, and for how long should they live before merging back? Two well-known answers — Git Flow and trunk-based development — sit at opposite ends of that spectrum, and the choice has real consequences for how often code integrates and how painful merges tend to be.
Git Flow
Git Flow (formalized by Vincent Driessen in 2010) defines a strict set of long-lived branches, each with a specific purpose:
main ─────●───────────────────────●───────── (production releases only)
\ /
release/1.2.0 ●───●───────────────● (stabilizing a release)
/
develop ──●────●────●────●────●────●────●────── (integration branch)
\ \ / /
feature/a ●───●───● / /
feature/b ●──●──● /
hotfix/1.1.1 ●──● (urgent production fix)
main— always reflects exactly what's in production; nothing merges here except finished releases and hotfixes.develop— the ongoing integration branch where finished features accumulate between releases.feature/*— one branch per feature, branched fromdevelop, merged back intodevelopwhen done. These can live for days or weeks.release/*— cut fromdevelopwhen preparing a release; only bug fixes land here while it stabilizes, then it merges into bothmain(tagged as a release) and back intodevelop.hotfix/*— branched directly frommainto patch a production issue urgently, merged into bothmainanddevelop.
What it gets you: a very clear, structured process — anyone can tell at a glance what a branch is for, and main is guaranteed to always be exactly what's deployed. It suits products with genuinely scheduled, versioned releases (installed desktop software, embedded firmware, anything with a slow, deliberate release cadence) where "release 2.3.0" is a real, meaningful, long-lived artifact.
Where it breaks down: feature branches that live for days or weeks drift further from develop every day they're open, and the eventual merge gets progressively more likely to conflict. For a web application deploying continuously — sometimes many times a day — the whole release/* branch concept adds process with little payoff, since there's no meaningfully distinct "release" separate from whatever is currently on main.
Trunk-based development
Trunk-based development flips the default: everyone works directly against one shared branch (main, the "trunk"), and feature branches, if they exist at all, live for hours, not weeks, before merging back.
main ──●──●──●──●──●──●──●──●──●──●──●──●──●──────
\ / \ / \ / \ /
●● ●● ● ●● (short-lived branches,
(few hours)(a day) (same day) (few hours) each merged same-day)
Every merge to main is expected to be deployable — enforced by CI running on every merge, not by a separate stabilization branch. Work that isn't finished yet, but whose code has already merged, is hidden behind a feature flag (covered below) rather than kept isolated on a long-lived branch.
What it gets you: integration happens constantly, in small increments, so conflicts are small and easy to resolve — the opposite of a branch that's drifted for three weeks and now touches half the files another branch also touched. It's the default at most companies practicing continuous deployment, because "always shippable" and "always merged" turn out to be nearly the same property.
Where it breaks down: it demands real CI discipline (a broken main blocks everyone, immediately) and a genuine cultural commitment to small, incremental changes rather than one sprawling branch for an entire feature — teams that adopt the label without the discipline often end up with a main that's frequently broken.
Comparing the two
| Git Flow | Trunk-based development | |
|---|---|---|
| Branch lifetime | Days to weeks (feature branches), longer for release branches | Hours to a day or two, if branches are used at all |
main always deployable |
Only after a release branch stabilizes | Yes, enforced continuously by CI |
| Merge conflict risk | Higher — branches drift further before merging | Lower — small, frequent merges |
| Unfinished work in progress | Isolated on its own branch until ready | Merged behind a feature flag, hidden from users |
| Best fit | Scheduled, versioned releases (desktop software, firmware) | Continuous deployment (most modern web/SaaS products) |
| Process overhead | Higher — explicit release/hotfix branch types | Lower — mostly just main and short branches |
Feature flags as an alternative to long-lived branches
A feature flag (or feature toggle) is a runtime condition — usually a config value or a call to a flag-management service — that decides whether a piece of already-merged code actually executes:
if (Feature::enabled('new-checkout-flow', $user)) {
return $this->renderNewCheckout($order);
}
return $this->renderLegacyCheckout($order);
This is the mechanism that makes trunk-based development practical for genuinely large or risky features: the code for a half-finished "new checkout flow" merges into main continuously, in small reviewable pieces, without ever being reachable by real users, because the flag is off in production. When the feature is actually ready, flipping the flag — not a giant merge — is what makes it live, and if a serious problem is found in production, flipping it back off is a much faster mitigation than reverting a large merge under pressure.
Feature flags and long-lived feature branches are solving the same underlying problem — "let unfinished work exist without breaking things for everyone else" — with an important difference in where the unfinished-ness lives:
| Long-lived feature branch | Feature flag | |
|---|---|---|
| Unfinished code lives... | Outside main, isolated until merge |
Inside main, merged but inactive |
| Integration happens | All at once, at merge time (the "merge day" everyone dreads) | Continuously, in small increments |
| Turning a feature on/off | Requires a merge (and possibly a deploy) | A config change, often instant, no deploy needed |
| Rollback if something's wrong | Revert the merge commit | Flip the flag off |
Flags aren't free, either — a codebase with dozens of stale, forgotten flags accumulates branching logic that never gets cleaned up, which is its own form of technical debt; a flag should have an owner and an expected removal date once the feature fully ships.
Common mistakes
- Adopting Git Flow's full branch taxonomy (
develop,release/*,hotfix/*) for a product that deploys continuously, adding real process overhead with little corresponding benefit. - Calling a workflow "trunk-based" while still letting feature branches live for weeks — the benefit of trunk-based development comes specifically from short-lived branches and frequent integration, not from the name.
- Introducing feature flags without ever removing them once a feature is fully shipped — a codebase can accumulate dozens of permanently-on flags that nobody remembers the purpose of, which is just long-lived-branch complexity relocated into runtime conditionals.
- Assuming trunk-based development means no branches at all — it typically still uses short-lived branches for individual changes; the defining trait is how quickly they merge back, not their complete absence.