Git Branching Strategies
Why Branching Strategy Matters
Most teams don’t think about branching strategy until something goes wrong. A developer pushes a half-finished feature to the main branch the night before a release, or a hotfix gets lost because nobody could figure out which branch was “production.” By that point, the team is already paying the cost of not having made a deliberate choice earlier.
Branching strategy is, at its core, an agreement about how work flows from an individual developer’s machine to production. That agreement needs to account for a few things that vary widely between teams: how frequently the team releases, how much risk the team can tolerate in production at any given moment, how large the team is and how often developers’ work collides, and whether there are regulatory or compliance constraints that demand a paper trail before any code reaches users.
A solo developer shipping a personal project has very different needs from a 50-person team maintaining a SaaS product with a weekly release window. A team under PCI-DSS compliance can’t treat every push to main as a potential production deploy. These constraints shape which branching model actually fits, and picking the wrong one creates friction that compounds over time. Long integration cycles, merge conflicts that burn whole days, or deploys that require heroic manual coordination are almost always symptoms of a branching model that doesn’t match the team’s actual constraints.
Branches as Cheap Pointers
Because a Git branch is only a named pointer to a commit, creating one costs almost nothing in storage or time. In older version control systems like SVN, branching was expensive and merging was painful enough that many teams avoided both, so every workflow below depends on this difference.
# List all local branches
git branch
# List all branches including remotes
git branch -a
# Create a new branch and switch to it
git switch -c feature/user-authentication
# Switch to an existing branch
git switch main
# Create a branch starting from a specific commit or tag
git switch -c hotfix/payment-null-check v2.3.1
# Delete a branch after merging
git branch -d feature/user-authentication
# Force delete (use when you know what you're doing)
git branch -D feature/user-authentication
The low cost of branching is what makes the various workflow models practical. You can create a branch for a one-hour task, merge it, and delete it without any meaningful overhead. The cost that does exist isn’t technical. It’s cognitive and social. Every active branch represents work in progress that eventually needs to integrate with everything else, and the longer branches live in isolation, the harder that integration becomes.
Trunk-Based Development
Trunk-based development is built around a single principle: the main branch (called “trunk” in this model) is always in a releasable state, and developers integrate their changes there frequently, ideally at least once a day.
In its purest form, every developer commits directly to the trunk. In practice, most teams using trunk-based development allow short-lived feature branches, but they put a strict time limit on them: two or three days at the most. The branch exists just long enough to open a pull request for code review, then it’s merged and deleted.
# Start a short-lived feature branch from main
git switch -c feature/add-payment-retry main
# Work and commit frequently
git add src/Payments/RetryPolicy.cs
git commit -m "add exponential backoff to payment retry"
# Keep up to date with trunk while working
git fetch origin
git rebase origin/main
# Push and open a PR; after approval, merge immediately
git push origin feature/add-payment-retry
The discipline this requires is significant. Every commit that lands on main needs to be safe to ship. That means tests pass, the build is green, and no partially-implemented work is visible to users. Feature flags (covered below) are what make this viable when features take weeks or months to build: the code lands in the trunk early and often, but the functionality is hidden behind a flag until it’s ready.
Trunk-based development works best when the team has strong automated testing, a fast CI pipeline, and a culture where small, focused commits are the norm. It pays off in dramatically reduced merge conflicts and a codebase that’s always in a known good state. Large engineering organizations such as Google run it at enormous scale on a single shared trunk, and it’s the approach most consistent with continuous integration’s original intent, which was that everyone integrates into the mainline at least daily.
Where it struggles is with teams that need formal gates before production, teams where junior developers need more structured review time before code reaches main, or teams working on long-horizon features where the work genuinely can’t be decomposed into small daily increments.
GitHub Flow
GitHub Flow emerged as a simplified workflow for teams practicing continuous deployment. It’s trunk-based in spirit but makes one step mandatory: all work happens on short-lived branches and reaches main only through a pull request.
GitHub’s documentation describes six steps:
- Create a branch from main with a descriptive name
- Make changes, committing as work progresses
- Open a pull request when the branch is ready for review, or earlier as a draft to share work in progress
- Address review comments with additional commits
- Merge the pull request
- Delete the branch
Many teams add a deployment step before the merge, deploying the branch to a staging or preview environment so reviewers can verify behavior. GitHub’s own description no longer includes it, and the flow works either way, as long as main stays deployable.
# Step 1: Branch from main
git switch -c feature/export-csv-reports main
# Step 2: Commit iteratively
git add src/Reports/CsvExporter.cs
git commit -m "implement CSV export for report data"
git add tests/Reports/CsvExporterTests.cs
git commit -m "add tests for CSV export edge cases"
# Step 3-4: Push and open a PR on GitHub
git push -u origin feature/export-csv-reports
# Step 5: After PR approval, merge via the GitHub UI or the GitHub CLI
gh pr merge --squash --delete-branch
The PR is the center of gravity in GitHub Flow. It’s where discussion happens, where CI results are visible, and where the decision to merge gets made. The branch names serve as living documentation of what work is in flight.
GitHub Flow works exceptionally well for teams deploying frequently from a single stable codebase. SaaS products with no formal release cycle are its natural home. It’s less suited to teams that need to maintain multiple versions in production simultaneously or that have formal release windows rather than continuous deployment.
GitFlow
GitFlow was introduced by Vincent Driessen in 2010 and became one of the most widely adopted branching models of the 2010s. It was designed for software with explicit versioned releases, and its structure reflects that. There are five branch types, each with a specific role, and the rules about which branches can merge into which are strict.
| Branch | Created from | Merges into | Holds |
|---|---|---|---|
| main | (permanent) | (receives merges) | Released code only. Every commit is a production release, tagged with a version number |
| develop | (permanent) | (receives merges) | The integration branch, where completed features accumulate |
| feature/* | develop | develop | One feature in progress |
| release/* | develop | main and develop | Stabilization for an upcoming release: bug fixes only, no new features |
| hotfix/* | main | main and develop | An urgent fix to a production release |
hotfix/* main release/* develop feature/*
│ │ │ │ │
│ tag: v1.0 │ │ │
│ │ │ │ ┌─── branch
│ │ │ │ │ from
│ │ │ │◄──────────┘ develop
│ │ │ │
│ │ │◄─── branch ────┤
│ │ │ from │
│ │ │ develop │
│ │ │ │
│ ┌────┤◄──── merge ───────┤ │
│ │ │ │──── merge ────►│
│ tag: v1.1 │ │ │
│ │ │ │
├─── branch ─────┤ │
│ from main │ │
│ │ │
├──── merge ────►│ │
│ │ │
└──── merge ─────┼───────────────────────────────────►│
│ │
tag: v1.0.1 │
# Starting a feature (from develop)
git switch -c feature/invoice-generation develop
# Finishing a feature
git switch develop
git merge --no-ff feature/invoice-generation
git branch -d feature/invoice-generation
# Starting a release branch
git switch -c release/2.4.0 develop
# Finishing a release
git switch main
git merge --no-ff release/2.4.0
git tag -a v2.4.0 -m "Release 2.4.0"
git switch develop
git merge --no-ff release/2.4.0
git branch -d release/2.4.0
# Starting a hotfix
git switch -c hotfix/2.4.1 main
# Finishing a hotfix
git switch main
git merge --no-ff hotfix/2.4.1
git tag -a v2.4.1 -m "Hotfix 2.4.1"
git switch develop
git merge --no-ff hotfix/2.4.1
git branch -d hotfix/2.4.1
The --no-ff flag forces a merge commit even when a fast-forward would be possible, preserving the history of what was a feature or release branch. This is intentional in GitFlow: the history is meant to be readable at a glance.
GitFlow makes sense when the software has versioned releases, when multiple versions need to be supported in production simultaneously (v2.x still getting security patches while v3.x is the current release), and when the release process itself is a significant ceremony with QA cycles, sign-offs, and coordinated deployment windows.
Vincent Driessen himself added a note to the original post in 2020 acknowledging that GitFlow is not the right choice for software delivered continuously as a service. For web applications where every push to main can go to production within minutes, GitFlow’s structure creates overhead without providing value. The develop branch becomes a traffic bottleneck, release branches sit idle while everyone waits for the QA cycle to finish, and the whole model that was supposed to create order instead creates confusion about where the “real” current state lives.
Release Branching for Slower Teams
Some teams can’t do continuous deployment, whether because of customer expectations, regulatory requirements, or the nature of the software itself (packaged software, mobile apps pending store review, firmware). For these teams, release branches serve as a stabilization zone before a version ships.
The pattern is simpler than GitFlow’s full ceremony. Development happens on the main branch or a develop branch. When a release is approaching, a release branch is cut and goes into a stabilization period where only fixes go in. The main branch continues moving forward with new features.
# Cut a release branch when the feature freeze begins
git switch -c release/3.2 main
# Fix the bug on main first, then copy that one commit to the release branch
git switch release/3.2
git cherry-pick abc1234 # abc1234 is the fix commit already merged to main
# Tag when releasing
git tag -a v3.2.0 -m "Release 3.2.0"
The key discipline is keeping release branches narrowly focused. A release branch that accumulates new features during the stabilization period defeats its purpose. The team needs to agree upfront that once the release branch is cut, only fixes go on it, and that no fix lives only on the release branch.
There are two ways to honor that second rule. Fixing on main first and cherry-picking the fix to the release branch, as above, guarantees main never misses a fix, and it’s the default in trunk-based teams. Fixing on the release branch first and then merging or cherry-picking the fix back to main suits cases where the bug only reproduces on the release code, but it relies on someone remembering the second step.
Teams maintaining multiple concurrent releases (like an enterprise software vendor supporting v2.x, v3.x, and v4.x simultaneously) end up with long-lived release branches that receive security and critical bug fix updates for years. This is manageable as long as the team has clear policies about which fixes go to which branches and who is responsible for those backports.
Feature Flags as a Branching Alternative
Feature flags, sometimes called feature toggles, are runtime configuration switches that control whether a piece of functionality is active. They let developers merge code into the main branch before the feature is ready for users, separating code deployment from feature release.
# With feature flags, there's no need for a long-lived feature branch.
# Merge small, complete increments to main behind a flag.
git switch -c feature/new-checkout-flow main
# Day 1: add the flag infrastructure and stub
git add src/Checkout/CheckoutService.cs
git commit -m "add new checkout flow behind ENABLE_NEW_CHECKOUT flag"
# Day 2: add core logic, still behind the flag
git add src/Checkout/NewCheckoutService.cs
git commit -m "implement payment step in new checkout flow"
# PR, review, merge to main: the feature is invisible to users
git push origin feature/new-checkout-flow
# When the feature is ready, enable the flag in configuration
# (environment variable, LaunchDarkly, Azure App Configuration, etc.)
# No code change is required, and with a flag service no redeployment either
Feature flags enable trunk-based development for work that spans weeks. They allow gradual rollouts (enable for 1% of users, then 10%, then everyone), A/B testing, and the ability to kill a feature instantly in production without a rollback deployment. They’re how large teams run continuous deployment for complex, long-horizon work.
The cost is code complexity. Every flag creates a conditional path that needs to be tested in both states. Flags that live too long accumulate in the codebase as dead weight. Teams that adopt feature flags need a process for retiring them after the feature is fully launched. A codebase littered with old flags for features that shipped two years ago is a maintenance liability.
Services like LaunchDarkly and Unleash provide flag management infrastructure, including targeting rules, audit trails, and flag lifecycle management. For simpler needs, environment variables or a configuration file managed by something like Azure App Configuration or AWS AppConfig work fine.
Choosing a Strategy
No strategy is universally correct. The right choice depends on factors specific to the team and the software.
| Factor | Points toward simpler models (TBD / GitHub Flow) | Points toward structured models (GitFlow / Release Branching) |
|---|---|---|
| Release cadence | Continuous deployment, several times per day or week | Formal release cycles: monthly, quarterly, or on customer demand |
| Team size | Small team, high trust, frequent communication | Large team, distributed time zones, formal review requirements |
| Risk tolerance | Can roll back or fix forward quickly | Any regression in production is a significant event |
| Customer model | SaaS, single codebase, all users on same version | On-premises, packaged, or mobile with multiple versions in the wild |
| Regulatory environment | No formal approval chain required | Audit trails, sign-offs, or change management required before production |
| Test automation maturity | Strong CI/CD, high test coverage | Relying on manual QA cycles before release |
In practice, most teams land somewhere on a spectrum. A startup with ten developers and strong CI/CD and no regulatory constraints should default to trunk-based development or GitHub Flow and resist the temptation to adopt GitFlow’s structure before they need it. A financial services company with quarterly releases, regulatory requirements, and a manual sign-off process before any production change has genuine reasons to want the structure that GitFlow or release branching provides.
The most common mistake is adopting GitFlow because it sounds serious and professional, when the team’s actual situation calls for something simpler. GitFlow’s overhead only pays for itself when the software has the release characteristics it was designed for. For continuous deployment, it’s a burden.
Common Anti-Patterns
Long-lived feature branches are the most common source of merge pain. A branch that diverges from main for three weeks accumulates a growing conflict surface. The longer a branch lives, the more the rest of the codebase has changed around the code on that branch, and the harder the eventual merge becomes. If a feature requires more than a few days of work, decompose it into smaller pieces that can be merged independently (with a feature flag to hide the incomplete work), or plan for frequent syncs with main.
# Don't wait weeks to sync. Rebase regularly to stay current.
git fetch origin
git rebase origin/main
# A rebased branch that was already pushed needs a force push.
# --force-with-lease refuses if someone else pushed to it in the meantime.
git push --force-with-lease
Merge-day chaos happens when a team lets everyone work in isolation for a sprint and then tries to integrate all the branches at once. This is sometimes called “big bang integration” and it reliably produces a painful afternoon of resolving conflicts, broken tests, and finger-pointing. The fix is continuous integration: merge to the shared branch frequently, not at the end of the sprint.
Branch-per-environment is a pattern where branches represent environments (“staging branch”, “qa branch”, “production branch”) and code is promoted by merging between them. This sounds intuitive but creates situations where the branches diverge, fixes applied directly to the production branch get lost, and nobody is quite sure what’s actually in any environment. CI/CD pipelines should manage environments by deploying specific commits or tags to each one, so branches represent work rather than infrastructure state.
Too many active branches create cognitive load. If a repository has 40 open branches and most of them haven’t been touched in weeks, the team has lost track of what’s in progress and what’s abandoned. Regular branch hygiene keeps the working set manageable: delete merged branches, close abandoned PRs, and set an expectation that stale branches need either a comment explaining the delay or a closure.
Merging Hygiene
When a branch is ready to integrate, the team has three options for how to record that integration in the history: a merge commit, squash-and-merge, and rebase-and-merge. Each creates different history, and teams should choose one approach deliberately rather than letting it vary by developer preference.
Feature branch: A ── B ── C ── D (4 commits)
┌─ Merge Commit ─────────────────────────────────────────────┐
│ main: ... ── X ──────────────────── M (merge commit) │
│ \ / │
│ feature: A ── B ── C ── D ─┘ │
│ Result: Full history preserved, M has two parents │
└─────────────────────────────────────────────────────────────┘
┌─ Squash and Merge ─────────────────────────────────────────┐
│ main: ... ── X ── S (single commit with all changes) │
│ Result: Clean linear history, individual commits lost │
└─────────────────────────────────────────────────────────────┘
┌─ Rebase and Merge ─────────────────────────────────────────┐
│ main: ... ── X ── A' ── B' ── C' ── D' (replayed commits)│
│ Result: Linear history, all commits preserved, new hashes │
└─────────────────────────────────────────────────────────────┘
Merge commits (git merge --no-ff) preserve the full history of the feature branch, including every commit made during development, plus a dedicated commit recording the merge event. The result is a non-linear history that shows exactly when branches diverged and converged.
# Creates a merge commit even if fast-forward is possible
git merge --no-ff feature/user-authentication
Use merge commits when the development history of a feature has value, specifically when the commit-by-commit narrative of how a feature was built is meaningful, or when the team is using GitFlow and the non-ff merge is part of the convention.
Squash-and-merge collapses all the commits on a feature branch into a single commit before merging. The main branch ends up with one commit per feature or bug fix, regardless of how many intermediate commits the developer made while working.
# Manually squash commits before merging
git merge --squash feature/user-authentication
git commit -m "add user authentication with OAuth2"
# GitHub and most Git hosts offer squash-and-merge as a button option
Squash-and-merge produces clean, readable history on main. Each line in the log corresponds to one meaningful unit of work. The trade-off is that the detailed development history, including the “fixed typo” and “WIP” commits, is discarded. For most teams using GitHub Flow or trunk-based development, this is the right call: main’s history should be a clear record of what shipped, not a transcript of how it was built.
Rebase-and-merge replays the commits from the feature branch on top of main, one at a time, without creating a merge commit. The result is a perfectly linear history where every commit appears as if it was always on main.
# Rebase the feature branch onto main, then fast-forward merge
git switch feature/user-authentication
git rebase main
git switch main
git merge feature/user-authentication # fast-forward, no merge commit
Rebase-and-merge is appealing because the history is linear and easy to follow with git log. The cost is that rebasing rewrites history, so the commits that land on main have new hashes and no longer match the ones on the feature branch. When a hosting platform’s rebase-and-merge button does the rewrite, that mostly means the merged branch should be deleted rather than reused. When developers rebase shared branches by hand, anyone who based work on the old commits has to recover, and the rebased branch needs a force push. Teams that use rebase-and-merge need clear rules about when rebasing is and isn’t allowed.
Most teams are best served by picking either squash-and-merge (for clean main history) or merge commits (for preserved branch history) and standardizing on one. Mixing strategies makes git log harder to reason about and creates confusion when tracing a bug to a specific change.
# Useful git log flags for understanding branch history
git log --oneline --graph --all # visual branch diagram
git log --oneline main..feature/foo # commits on feature not yet on main
git log --follow -p src/Payments.cs # full history of a file including renames
The choice of merge strategy doesn’t change the code that ends up on main. It changes the story that the commit history tells, and that story matters most when something goes wrong and you’re using git bisect to binary-search the history for the commit that introduced a regression, or when you’re trying to understand why a particular decision was made six months ago. A history kept with meaningful commit messages and a consistent merge strategy answers those questions directly. A history full of “WIP”, “fix”, and “asdf” commits merged from a dozen different strategies tells you almost nothing.
Found this guide helpful? Share it with your team:
Share on LinkedIn