Git Branching Strategies

πŸ“– 17 min read

Table of Contents


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 aren’t abstract concerns; they 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

Before comparing strategies, it helps to understand what a branch actually is. In Git, a branch is nothing more than a lightweight pointer to a specific commit. Creating one costs almost nothing in terms of storage or performance. This is a meaningful difference from older version control systems like SVN, where branching was expensive and merging was painful enough that most teams avoided it.

# List all local branches
git branch

# List all branches including remotes
git branch -a

# Create a new branch and switch to it (classic syntax)
git checkout -b feature/user-authentication

# Create a new branch and switch to it (modern syntax, Git 2.23+)
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. It’s the model used by teams like Google and Meta at enormous scale, and it’s the approach most consistent with continuous integration’s original intent.

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 adds one formal step: all work happens on feature branches and merges to main through a pull request.

The workflow has six steps:

  1. Create a branch from main with a descriptive name
  2. Add commits as work progresses
  3. Open a pull request when the branch is ready for review (or even earlier, as a draft PR to share work in progress)
  4. Discuss and review; make additional commits in response to feedback
  5. Deploy the branch to a staging environment to verify behavior before merging
  6. Merge to main, deploy to production
# 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 6: After PR approval, merge via GitHub UI or CLI
# gh pr merge --squash (using GitHub CLI)

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.

The five branch types are:

  • main: contains only released code; every commit on main is a production release, tagged with a version number
  • develop: the integration branch; completed features merge here
  • feature/*: created from develop, merged back to develop when complete
  • release/*: created from develop when a release is being prepared; only bug fixes go here, not new features; merges to both main and develop when the release ships
  • hotfix/*: created from main to fix critical production bugs; merges to both main and develop
  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

# Only bug fixes and release-specific changes go here
git switch release/3.2
git cherry-pick abc1234   # pick a specific fix from main

# Tag when releasing
git tag -a v3.2.0 -m "Release 3.2.0"

# Backport the fix to main so it isn't lost
git switch main
git cherry-pick abc1234

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: once the release branch is cut, only fixes go on it, and every fix that goes on the release branch also gets backported to main.

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 β€” 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 or new deployment required

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

Several patterns look reasonable at first but create predictable problems.

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

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. Environments should be managed by CI/CD pipelines that deploy specific commits or tags to specific environments; branches should represent work, not 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 SHAs   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

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 problem is that rebasing rewrites history: the commits on the feature branch get new SHA hashes. This is fine for private branches, but it means the branch’s history in the remote is incompatible after a rebase, requiring a force push. Teams that use rebase-and-merge need to have 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 only changes the story that the commit history tells. That story matters most when something goes wrong and you’re using git bisect to find a regression, or when you’re trying to understand why a particular decision was made six months ago. A history that’s been thoughtfully maintained with meaningful commit messages and a consistent merge strategy is a meaningful artifact of the team’s work. A history full of β€œWIP”, β€œfix”, and β€œasdf” commits merged from a dozen different strategies tells you almost nothing.

# git bisect helps find the commit that introduced a regression
git bisect start
git bisect bad                     # current commit is broken
git bisect good v2.3.0             # this tag was working
# Git checks out commits for you to test; mark each as good or bad
git bisect good
git bisect bad
# Git narrows down to the offending commit
git bisect reset                   # return to original HEAD when done

Found this guide helpful? Share it with your team:

Share on LinkedIn