#Trunk-based development vs GitFlow
AddedA branching strategy is a team's answer to one question: how long does a change live apart from everyone else's before it's integrated? Everything else (which branches exist, who merges what, when you tag) follows from that answer. The longer changes stay apart, the more they drift, and the more painful and risky the eventual merge.
The mental model: think of the main branch as a shared document and each branch as a private copy someone is editing. If you copy it in the morning and paste your edits back by lunch, nothing much has changed underneath you. If you keep your copy for three weeks while ten colleagues do the same, merging back means reconciling three weeks of everyone's edits at once, usually right before a deadline. Trunk-based development keeps the copies small and short-lived; GitFlow accepts long-lived copies and adds structure to manage them.
GitFlow was described by Vincent Driessen in "A successful Git branching model" (5 January 2010). It has two permanent branches, main (he called it master: production releases only) and develop (the integration branch), plus three kinds of temporary branch: feature/* from develop, release/* from develop (stabilise, bump the version, merge into both main and develop), and hotfix/* from main (merge into both). Every merge uses --no-ff so each branch stays visible in the graph. In a "Note of reflection" added on 5 March 2020, Driessen wrote that for software delivered continuously, such as web apps, teams should adopt a much simpler workflow like GitHub flow, and that git-flow still suits explicitly versioned software, or software where several versions are supported in the wild.
Trunk-based development keeps one long-lived branch (main, the trunk). Everyone integrates into it at least daily, through short-lived branches and pull requests or by committing directly. Unfinished work is hidden behind feature flags instead of being kept on a branch. DORA lists it as one of its capabilities (dora.dev, "Trunk-based development", checked October 2026), with three practices: three or fewer active branches in the repository, branches merged to trunk at least once a day, and no code freezes or integration phases. The page cites the 2016 and 2017 State of DevOps reports for the link to delivery performance. Paul Hammant's trunkbaseddevelopment.com is the long-form reference.
Here are both models running for real: two features, a release and a hotfix, shipped each way, with git log --graph showing the history each one leaves behind.
// The same three changes and one hotfix, shipped with GitFlow and with trunk-based development.
// Real git in throwaway repositories with fixed names and dates (deterministic hashes, Git 2.54).
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
function repo() {
const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
const date = `${1767603600 + 60 * minute++} +0000`;
return execFileSync("git", args, {
cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
}).trimEnd();
};
let n = 0;
const commit = (msg) => { writeFileSync(join(dir, `f${n++}.txt`), msg); git("add", "."); git("commit", "-q", "-m", msg); };
git("-c", "init.defaultBranch=main", "init", "-q");
commit("Initial commit");
return { git, commit, done: () => rmSync(dir, { recursive: true, force: true }) };
}
const merge = (git, into, from, msg) => { git("switch", "-q", into); git("merge", "-q", "--no-ff", from, "-m", msg); };
const flow = repo();
try {
const { git, commit } = flow;
git("switch", "-q", "-c", "develop");
for (const f of ["search", "filters"]) {
git("switch", "-q", "-c", `feature/${f}`, "develop");
commit(`Add ${f}`);
merge(git, "develop", `feature/${f}`, `Merge feature/${f} into develop`);
}
git("switch", "-q", "-c", "release/1.1", "develop");
commit("Bump version to 1.1.0");
merge(git, "main", "release/1.1", "Merge release/1.1");
git("tag", "v1.1.0");
merge(git, "develop", "release/1.1", "Merge release/1.1 into develop");
git("switch", "-q", "-c", "hotfix/1.1.1", "main");
commit("Fix crash on empty search");
merge(git, "main", "hotfix/1.1.1", "Merge hotfix/1.1.1");
git("tag", "v1.1.1");
merge(git, "develop", "hotfix/1.1.1", "Merge hotfix/1.1.1 into develop");
for (const b of ["feature/search", "feature/filters", "release/1.1", "hotfix/1.1.1"]) git("branch", "-q", "-D", b);
console.log("GitFlow: main, develop, feature, release and hotfix branches");
console.log(git("log", "--graph", "--oneline", "--decorate", "--all"));
console.log(`merge commits: ${git("rev-list", "--merges", "--count", "--all")}`);
} finally { flow.done(); }
const trunk = repo();
try {
const { git, commit } = trunk;
for (const f of ["search", "filters"]) {
git("switch", "-q", "-c", f); // a branch that lives for hours, not weeks
commit(`Add ${f} (behind a feature flag)`);
git("switch", "-q", "main");
git("merge", "-q", "--ff-only", f);
git("branch", "-q", "-d", f);
}
git("tag", "v1.1.0"); // release straight from main
commit("Fix crash on empty search");
git("tag", "v1.1.1");
console.log("\nTrunk-based: short-lived branches, every change lands on main");
console.log(git("log", "--graph", "--oneline", "--decorate", "--all"));
console.log(`merge commits: ${git("rev-list", "--merges", "--count", "--all")}`);
} finally { trunk.done(); }GitFlow: main, develop, feature, release and hotfix branches
* 26e394b (HEAD -> develop) Merge hotfix/1.1.1 into develop
|\
* \ 3ed5e7f Merge release/1.1 into develop
|\ \
| | | * 8e7c20b (tag: v1.1.1, main) Merge hotfix/1.1.1
| | | |\
| | | |/
| | |/|
| | * | 2811ba2 Fix crash on empty search
| | |/
| | * b80eec3 (tag: v1.1.0) Merge release/1.1
| | |\
| | |/
| |/|
| * | b00eb21 Bump version to 1.1.0
|/ /
* | a1e177e Merge feature/filters into develop
|\ \
| * | 0148762 Add filters
|/ /
* | fbb53ee Merge feature/search into develop
|\ \
| |/
|/|
| * 4fdd81b Add search
|/
* 5483f1f Initial commit
merge commits: 6
Trunk-based: short-lived branches, every change lands on main
* f2c0251 (HEAD -> main, tag: v1.1.1) Fix crash on empty search
* 84bf6c6 (tag: v1.1.0) Add filters (behind a feature flag)
* 1ca0748 Add search (behind a feature flag)
* 5483f1f Initial commit
merge commits: 0- The model creates two throwaway repositories. Every
gitcall gets fixed names, emails and dates (a fake clock advancing a minute per call), andGIT_CONFIG_GLOBAL=/dev/nullso nothing from the machine's own Git settings leaks in. That's why the hashes are identical on every run: a commit hash is a hash of its content, its parents, its author, its committer and their dates. - GitFlow:
developbranches offmain. Each feature gets its ownfeature/*branch offdevelopand comes back with a--no-ffmerge, so the graph shows a bump for each. Thenrelease/1.1branches offdevelop, gets a version-bump commit, and is merged twice: intomain(taggedv1.1.0) and back intodevelop, so the bump isn't lost. - The hotfix repeats the double merge:
hotfix/1.1.1branches offmainat the release, gets the fix, merges intomain(taggedv1.1.1) and intodevelop. Forgetting that second merge is the classic GitFlow bug: the next release fromdevelopsilently brings the crash back. - The result is the tangle at the top of the output: six merge commits and graph lines crossing each other for four real changes. Nothing is wrong with it; it's the cost of keeping every branch's identity in history. Reading it needs
--first-parentor a good GUI. - Trunk-based: each feature lives on a branch for a few hours, merges with
--ff-only(no merge commit, because nothing else landed meanwhile) and is deleted. The release is just a tag onmain; the hotfix is the next commit onmain, taggedv1.1.1. The history is a straight line of four commits and zero merge commits. - The difference isn't the commands; it's how long work stays apart. GitFlow integrates features into
mainonly at release time; trunk-based integrates every change intomainthe same day, somainis always the latest truth and always releasable.
Side by side
| GitFlow | Trunk-based development | |
|---|---|---|
| Long-lived branches | main + develop (+ release branches during stabilisation) | main only (release branches optional, short-lived) |
| How long a change lives apart | days to weeks (feature branch → develop → release) | hours, at most a day or two |
| Unfinished work | stays on its feature branch | merged, but switched off with a feature flag |
| Release | release/* branch, merged into main and develop, tagged | tag a commit on main (or cut a release branch from it) |
| Hotfix | hotfix/* from main, merged into main and develop | fix on main, cherry-pick to a release branch if one exists |
| Needs | discipline about the double merges | fast CI, good tests, feature flags, small PRs |
| Fits | explicitly versioned software on a release schedule (Driessen's 2020 note); its one main holds only the latest release, so several supported lines need extra long-lived branches the original model doesn't define | web apps and APIs deployed continuously; with a release branch per supported line, also versioned software such as device firmware |
If everything merges to main daily, a half-built feature is on main too. A feature flag (also called a toggle) is a runtime switch that keeps it dark until it's ready:
// The new pairing flow is merged and deployed, but only shown where the flag is on.
export function pairingScreen(user, flags) {
if (flags.isEnabled("new-pairing-flow", { userId: user.id, tenant: user.tenant })) {
return renderNewPairingFlow(user);
}
return renderLegacyPairing(user);
}- Both code paths ship in the same build.
flags.isEnabledasks a flag service or a config file whether this user or tenant should see the new flow. - Developers merge small slices of the new flow every day; with the flag off in production, users never see the half-finished screens, but CI tests both paths.
- When it's ready, the team turns the flag on for internal users, then a percentage of customers, then everyone. Turning it off again is the fastest rollback there is: no deploy needed.
- The last step is deleting the flag and the old path. Flags are technical debt with a date on them: give each one an owner and a removal ticket, or the codebase fills up with dead branches of
ifstatements.
GitHub flow, the "much simpler workflow" Driessen points to, is trunk-based development with pull requests: branch off main, push, open a pull request, get review and green CI, merge, deploy. Most teams that say "we use trunk-based development" mean this.
Trunk-based doesn't forbid release branches. If you ship something that needs a stabilisation window (a mobile app in store review, firmware on devices), trunkbaseddevelopment.com describes cutting a release branch from trunk just in time, with developers continuing on trunk, fixes made on trunk first and cherry-picked to the branch, and the branch never merged back. That's covered with a runnable example in Release branches, tags and hotfixes.
Your team builds a device-management web UI deployed to a cloud service several times a week, and a REST API that the same service exposes. A colleague proposes GitFlow "because it's the standard". What do you say?
Show answer
Propose trunk-based development with short-lived PR branches, and explain why GitFlow's main benefit doesn't apply.
- Ask the deciding question: how many versions do we support at the same time? A cloud web UI and API have exactly one version in production (the one deployed), so GitFlow's machinery for parallel versions,
developplus release and hotfix branches with double merges, buys nothing. - Name the costs it would add: features integrate late (at release time), the double merges are easy to forget, and the history is hard to read, as the model's six merge commits for four changes showed.
- Offer the alternative concretely:
mainprotected and always releasable, PR branches that live a day or two, CI on every PR, feature flags for work that isn't ready, releases as tags onmain. - Leave a door open: if an API version must be supported for older clients, that's handled with API versioning in the code (
/v1,/v2), not with long-lived Git branches. A release branch is only needed if something ships on its own cadence, and then it's cut frommainand receives cherry-picked fixes.