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