Long-lived branches feel safe. Your feature is isolated, nobody breaks it, and you merge when it is "done". Then merge day arrives: three weeks of drift, conflicts in files you never touched, and a release that bundles twelve changes nobody can test in isolation.
Trunk-based development takes the opposite bet. Everyone integrates into one branch, at least daily, and the main branch is always releasable. The thing that makes this possible for work that takes longer than a day is separating deploy from release: code ships to production switched off, and a feature flag decides when users see it.
This post is a cheatsheet for both halves. It covers the rules, the branching variants, the CI that has to back them up, the kinds of feature flags and how to implement them, and the discipline that keeps flags from becoming the next source of outages.
1. The vocabulary
| Term | Meaning | Why it matters |
|---|---|---|
| Trunk / mainline | The single shared branch (main) everyone integrates into |
The source of truth. If it is red, everyone stops. |
| Trunk-based development (TBD) | Developers merge small changes into trunk at least daily; no long-lived feature branches | The precondition for continuous integration in the literal sense. |
| Short-lived feature branch | A branch that lives hours to a day or two, merged via pull request | The common, review-friendly form of TBD. Days, not weeks. |
| Continuous integration (CI) | Integrating everyone's work into trunk frequently, verified by an automated build and tests | Not "we have a CI server". Running CI on a three-week branch is not continuous integration. |
| Continuous delivery (CD) | Trunk is always in a deployable state; deploying is a business decision | TBD plus a pipeline you trust. |
| Deploy | Putting a new version of the code onto production infrastructure | A technical event. Should be routine and boring. |
| Release | Making a behavior available to users | A product decision. Flags let you do it separately from deploy. |
| Feature flag / toggle | A runtime condition that switches a code path on or off without a redeploy | The tool that decouples deploy from release. |
| Branch by abstraction | Replacing a component in place behind an interface, old and new coexisting in trunk | How large refactors happen without a long-lived branch. |
| Dark launch | Running new code in production without exposing its results to users | Tests performance and correctness under real load before release. |
| Progressive delivery | Releasing to a growing share of users or regions, watching metrics at each step | Limits the blast radius of a bad change to the first slice. |
| Kill switch | A flag that turns a feature or dependency off instantly during an incident | The fastest rollback you will ever have. |
2. The rules, as DORA measures them
DORA's research (the team behind the Accelerate State of DevOps reports) found that teams practicing trunk-based development achieve higher software delivery and operational performance. Its capability guide reduces the practice to three checkable rules:
| Rule | Target | What to watch |
|---|---|---|
| Few active branches | Three or fewer in the application repository | Count branches older than two days. That number is your real integration debt. |
| Merge to trunk often | At least once a day per developer | Median branch lifetime; PRs open longer than 24 hours |
| No code freezes, no integration phases | Trunk is releasable at any time | Any calendar event called "merge week" or "stabilization sprint" |
DORA also names the common blockers, and none of them are about Git:
- Heavy code review. If review takes days, people batch changes to avoid paying the cost twice. Small PRs need fast, ideally same-day, reviews.
- Asynchronous review only. Waiting overnight for a comment round trip doubles branch lifetime. Pairing or quick synchronous reviews help.
- Weak automated tests. If trunk is not protected by fast tests that run before merge, "always releasable" is a hope, not a property.
A note for 2026: the DORA 2025 report on AI-assisted development describes AI as an amplifier of whatever system it lands in. Coding assistants make it cheap to produce large diffs. Small batches, fast review and a strong test suite are what keep that extra output from turning into larger, riskier merges.
3. Two styles of trunk-based development
| Style | How it works | Good for | Watch out for |
|---|---|---|---|
| Commit directly to trunk | Developers push small commits to main, usually after running tests locally; often with pair or mob programming |
Small, senior, co-located teams; very fast feedback | Needs excellent tests and discipline; hard to combine with mandatory review |
| Short-lived branches + PR | Branch, open a PR within hours, review, merge within a day or two | Most teams, regulated environments, open source | PRs that quietly grow into week-long branches |
A typical day with short-lived branches:
git switch main && git pull --rebase
git switch -c feat/checkout-tax-rounding
# ... small change, tests, commit ...
git commit -am "Round tax per line item behind flag checkout.tax-per-line"
# Rebase on the latest trunk before opening the PR (keeps history linear)
git fetch origin && git rebase origin/main
git push -u origin feat/checkout-tax-rounding
gh pr create --fill --base main
# After approval: squash-merge and delete the branch
gh pr merge --squash --delete-branch
What about releases?
TBD does not forbid release branches. It forbids developing on them.
- Release from trunk (tag a commit and deploy it) when you deploy continuously.
- Cut a release branch from trunk when you must support a shipped version (mobile apps, on-premise software). Fixes land on trunk first and are cherry-picked to the release branch, never the other way around. No new features on release branches.
4. The CI that makes it safe
Trunk-based development moves risk from "merge day" to "every merge". That only works if every merge is verified quickly.
| Practice | Why | Practical target |
|---|---|---|
| Required checks before merge | Trunk must never be knowingly broken | Branch protection with required status checks |
| Fast build | Slow pipelines push people back to batching | Under 10 minutes for the merge-blocking suite; run slower suites after merge |
| Merge queue | Two green PRs can still break trunk together. A queue tests each PR on top of the ones ahead of it | GitHub's merge queue (generally available since July 2023), GitLab merge trains, or equivalents |
| Fix or revert in minutes | A red trunk blocks everyone | Team rule: if trunk is red for more than 10 minutes, revert first, debug later |
| Small PRs | Review quality falls sharply with diff size | A few hundred changed lines at most; one concern per PR |
| Tests at every level | Flags multiply code paths (section 7) | Unit tests with the flag on and off; a smoke test of the production flag configuration |
A minimal GitHub Actions workflow that supports a merge queue:
# .github/workflows/ci.yml
name: ci
on:
pull_request:
merge_group: # required so the merge queue can run checks
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@<full-commit-sha>
- uses: astral-sh/setup-uv@<full-commit-sha>
- run: uv sync --locked
- run: uv run pytest -q
5. Large changes without long-lived branches
"This feature takes six weeks" is not a reason for a six-week branch. Three techniques cover nearly every case.
| Technique | Use it when | How |
|---|---|---|
| Feature flag | New user-facing behavior | Merge incomplete work switched off; turn it on when ready (sections 6 to 8) |
| Branch by abstraction | Replacing a library, database, service or major component | 1) Introduce an interface over the old implementation. 2) Move callers to the interface. 3) Build the new implementation behind it. 4) Switch (often with a flag). 5) Delete the old one |
| Keystone interface | New feature with a single entry point (a button, a route, an API field) | Build everything behind it first; add the visible entry point last. Nothing is reachable until then |
| Expand and contract | Database or API schema changes | Expand (add the new column or field, write both), migrate readers, contract (remove the old one). Each step is a separate, backward-compatible deploy |
Branch by abstraction in miniature:
from typing import Protocol
class TaxCalculator(Protocol):
def tax(self, lines: list[tuple[int, float]]) -> int: ...
class LegacyTax:
"""Rounds once on the order total (old behavior). Amounts in cents."""
def tax(self, lines: list[tuple[int, float]]) -> int:
return round(sum(amount * rate for amount, rate in lines))
class PerLineTax:
"""Rounds per line item (new behavior, required in some jurisdictions)."""
def tax(self, lines: list[tuple[int, float]]) -> int:
return sum(round(amount * rate) for amount, rate in lines)
def tax_calculator(flags, ctx) -> TaxCalculator:
# Step 4: the switch. Step 5 deletes LegacyTax and this function.
if flags.is_enabled("checkout.tax-per-line", ctx):
return PerLineTax()
return LegacyTax()
6. The four kinds of feature flags
Pete Hodgson's classification on Martin Fowler's site is still the most useful one. The category decides how long a flag should live and how it should be managed.
| Kind | Purpose | Lifetime | Changes | Example |
|---|---|---|---|---|
| Release toggle | Hide unfinished or unreleased work in trunk | Days to weeks | Rarely, per deploy or release | checkout.tax-per-line |
| Experiment toggle | A/B or multivariate tests | Hours to weeks | Per request or user | pricing-page.variant |
| Ops toggle | Control operational behavior: degrade, shed load, kill switch | Short, or permanent for kill switches | Instantly, during incidents | recommendations.enabled |
| Permission toggle | Enable features for specific users or plans (beta, premium, internal) | Months to years | Per user | reports.export-pdf for paid plans |
Two practical consequences:
- Release and experiment toggles are temporary. Each one should have an owner and a removal date when it is created.
- Permission toggles are product logic. Once a flag is a permanent entitlement, it often belongs in your authorization model, not in a flag system.
7. Implementing flags: evaluation, targeting, rollout
A flag check takes a flag key, an evaluation context (user, account, region, environment) and a default. The default matters: it is what you get when the flag service is down or the key is missing, so it must be the safe behavior.
A self-contained evaluator with deterministic percentage rollout:
import hashlib
from dataclasses import dataclass, field
@dataclass
class Flag:
key: str
enabled: bool = False # global kill switch
rollout_percent: int = 0 # 0-100
allow: set[str] = field(default_factory=set) # always on for these users
deny: set[str] = field(default_factory=set) # always off for these users
def bucket(flag_key: str, user_id: str) -> int:
"""Stable 0-99 bucket: the same user always lands in the same bucket per flag."""
digest = hashlib.sha256(f"{flag_key}:{user_id}".encode()).hexdigest()
return int(digest[:8], 16) % 100
def is_enabled(flag: Flag | None, user_id: str, default: bool = False) -> bool:
if flag is None:
return default # unknown flag: safe default
if not flag.enabled or user_id in flag.deny:
return False
if user_id in flag.allow:
return True
return bucket(flag.key, user_id) < flag.rollout_percent
tax = Flag("checkout.tax-per-line", enabled=True, rollout_percent=10, allow={"qa-team"})
users = [f"user-{i}" for i in range(10_000)]
share = sum(is_enabled(tax, u) for u in users) / len(users)
print(f"{share:.1%} of users see the new tax logic") # close to 10%
print(is_enabled(tax, "qa-team")) # True
print(is_enabled(None, "user-1")) # False (safe default)
Hashing the flag key together with the user ID keeps rollouts sticky (raising 10% to 25% keeps the first 10% in) and independent across flags (the same users are not always the guinea pigs).
In production, use a flag service or library rather than your own. To avoid tying application code to one vendor, OpenFeature (a CNCF incubating project since December 2023) defines a vendor-neutral API with providers for most commercial and open source flag systems:
from openfeature import api
from openfeature.evaluation_context import EvaluationContext
# api.set_provider(...) # your vendor's or an open source provider, set once at startup
client = api.get_client()
ctx = EvaluationContext(targeting_key=user.id, attributes={"plan": user.plan})
if client.get_boolean_value("checkout.tax-per-line", False, ctx):
...
Implementation rules that hold for any system:
| Rule | Why |
|---|---|
| Evaluate once per request, at the edge of the feature | Scattered checks for the same flag drift apart and are hard to remove |
| Default to off (old behavior) on errors | Flag outages should degrade to known behavior, not to the experiment |
| Keep flag config out of the deploy | The point is to change behavior without a redeploy |
| Audit every flag change | A flag flip is a production change. Record who, when, and why |
| Do not reuse flag names | An old flag key may still exist in an old binary somewhere (see section 9) |
8. Progressive delivery: release in steps
A flag on its own is a light switch. Combined with metrics, it becomes a controlled rollout.
| Stage | Who gets it | Hold until |
|---|---|---|
| 0. Dark launch | Nobody sees results; new path runs in shadow or for internal traffic | Errors and latency match the old path |
| 1. Internal | Employees, test accounts | No bugs found in daily use |
| 2. Canary | 1-5% of users, or one region | Error rate, latency and business metrics within guardrails for a set period |
| 3. Ramp | 10%, 25%, 50% | Same checks at each step |
| 4. Full release | 100% | Stable for a full business cycle |
| 5. Clean up | Remove the flag and the old code path | Merged and deployed |
Define the guardrail metrics and the rollback trigger before stage 1. "Roll back if checkout error rate rises by more than 0.5 percentage points over the control group" is a decision you can automate. "Roll back if something looks wrong" is not.
This applies to configuration and infrastructure as much as to features. In Google Cloud's June 12, 2025 outage, a new quota-checking code path in Service Control was not protected by a feature flag; a policy change with blank fields hit it in every region and crashed the binaries. Google's incident report states that with flag protection the issue would have been caught in staging, and commits to making changes to critical binaries flag-protected and disabled by default.
9. Flag debt: the part teams skip
Every flag doubles the number of code paths it touches. Ten interacting flags are over a thousand combinations, and nobody tests them all.
The cautionary tale is Knight Capital in August 2012. According to the SEC order, a new deployment repurposed an old flag that had once activated retired "Power Peg" code. A technician did not deploy the new code to one of eight servers, so on that server the reused flag triggered the dead code. In about 45 minutes the system sent millions of orders and Knight lost more than $460 million. Dead code, a reused flag and a manual deploy: every item in this section was missing.
| Practice | How |
|---|---|
| Owner and expiry on creation | Every release or experiment flag gets an owner and a removal date in its metadata or ticket |
| Removal is part of "done" | The story is not done until the flag and the losing code path are deleted |
| Cap the inventory | A team-level limit on live temporary flags forces cleanup before new flags |
| Detect stale flags | Report flags at 100% or 0% for more than N weeks, and flags not evaluated recently |
| Test the three configurations that matter | Current production config, production config plus flags about to be enabled, and all new flags off (the fallback) |
| Never repurpose a flag key | Retire it and create a new one |
A cheap "time bomb" test that fails CI when a temporary flag outlives its date:
from datetime import date
TEMPORARY_FLAGS = {
# key: (owner, remove_by)
"checkout.tax-per-line": ("payments-team", date(2026, 6, 15)),
"pricing-page.variant": ("growth-team", date(2026, 5, 31)),
}
def test_no_expired_flags():
expired = {k: v for k, v in TEMPORARY_FLAGS.items() if v[1] < date.today()}
assert not expired, f"Remove or re-justify expired flags: {expired}"
10. Anti-patterns
| Anti-pattern | Symptom | Fix |
|---|---|---|
| "Trunk-based" with week-long PRs | Branches named after features, merged at sprint end | Measure branch age; split work with flags and branch by abstraction |
| Flags instead of tests | "It's behind a flag, so it's fine" | Flagged code is production code. It still needs tests and review |
| Flag checks everywhere | The same key checked in 30 places | One decision point per feature; pass the result down |
| Nested flags | if a: if b: if not c: |
Combine into a single flag or remove the older ones first |
| Long-lived release toggles | Flags at 100% for a year | Expiry dates and a stale-flag report |
| Code freeze before releases | A week where nobody merges | Fix the pipeline and test gaps that make trunk unreleasable |
| Unsafe defaults | Flag service outage turns on half-finished features | Default to the old path in code |
Why this matters
- Small batches are the lever. Integration pain, review quality and blast radius all scale with change size. Trunk-based development keeps batches small by design.
- Deploy and release are different decisions. Separating them lets engineering ship continuously while product decides when users see a change.
- Flags are a safety system. Percentage rollouts and kill switches turn a bad change into a partial, reversible event instead of a global outage.
- Flags are also inventory. Every flag has a carrying cost. Owners, expiry dates and removal as part of "done" keep the cost bounded.
- The pipeline is the contract. Fast required checks, a merge queue and a "revert first" rule are what make "always releasable" true.
Final Thoughts
Trunk-based development is less about Git than about habits: merge small changes daily, keep trunk green, and never let a branch become the place where work hides. Feature flags make those habits possible for work that is bigger than a day.
Start by measuring what you have: count branches older than two days and flags older than their purpose. Then move one team to short-lived branches with a merge queue, put the next feature behind a flag with an owner and an expiry date, and delete the flag when it ships.
Sources
- DORA, Capabilities: Trunk-based development: three or fewer active branches, merging at least daily, no code freezes; common pitfalls and measures.
- DORA, 2025 State of AI-assisted Software Development Report (September 2025): AI as an amplifier of existing practices.
- Paul Hammant, Trunk Based Development: committing to trunk vs. short-lived branches, branch by abstraction, release branches and cherry-picking.
- Martin Fowler, Continuous Integration (2024): integration frequency, keeping the mainline healthy, fixing broken builds immediately.
- Pete Hodgson, Feature Toggles (aka Feature Flags) (October 2017): release, experiment, ops and permission toggles; toggle debt; testing configurations.
- Martin Fowler, BranchByAbstraction (2014).
- GitHub, GitHub merge queue is generally available (July 2023).
- CNCF, OpenFeature becomes a CNCF incubating project (December 2023).
- Google Cloud, Service Health incident report for the June 12, 2025 outage (June 2025): missing feature flag protection and the commitment to flag-protect critical binary changes.
- U.S. Securities and Exchange Commission, In the Matter of Knight Capital Americas LLC (October 2013): repurposed flag, Power Peg code, more than $460 million lost in about 45 minutes.