Skip to content

Git Flow Explained: Workflow, Branches, Pros and Cons

Lesson 4 of 8Intermediate10 min readModern Git Workflows · BranchingVerified: Git 2.43.0 on Ubuntu 24.04

Git Flow is a branching model with two long-lived branches — main and develop — plus three short-lived branch types: feature, release and hotfix. It was published by Vincent Driessen in 2010 in a post titled A successful Git branching model, and for several years it was the default answer to “how should we use Git?”

It is now widely criticised, sometimes unfairly. The model solves real problems; the criticism is mostly that many teams adopted it without having those problems.

In 2010, most software was released, not deployed. You cut version 1.4, tested it for a fortnight, shipped it to customers, and then supported it while 1.5 was being built. Some customers stayed on 1.3 and still needed security fixes.

That world has specific requirements. You need somewhere to stabilise 1.4 that does not block work on 1.5. You need to fix 1.3 without shipping unrelated new features. You need a record of exactly what each version contained.

Git Flow answers all three, and its complexity is the shape of those answers. Driessen later added a note to the original post observing that teams building continuously delivered web software are probably better served by a simpler model — an unusually honest revision by an author whose work had become an industry default.

Git Flow: two permanent branches with three temporary types

Four lanes. The top lane, main, has an initial commit and two release tags. Below it, develop runs continuously and receives feature work. A feature lane branches from develop and merges back. A release lane branches from develop, receives stabilisation commits, and merges into both main and develop.

v1.0v1.1D0D1D2D3D4F1F2R1R2maindevelopfeature/xrelease/1.1release/1.1 merges into both main (tagged) and develop, so stabilisation fixes are not lost.
BranchLifetimeBranches fromMerges intoPurpose
mainPermanentProduction-released code only; every commit is a release
developPermanentmain (once)Integration branch for the next release
feature/*TemporarydevelopdevelopOne feature
release/*Temporarydevelopmain and developStabilise a version
hotfix/*Temporarymainmain and developUrgent production fix

The two rules that carry most of the model:

main contains only released code. Every commit on main is a released version, normally tagged. You can always answer “what is in production?” by reading main.

Release and hotfix branches merge into two places. This is the detail people forget and the source of the model’s most common bug. A fix made on a release branch must reach develop too, or the next release silently reintroduces the problem.

Ordinary feature branches, with one difference: they start from and return to develop, not main.

Terminal window
git switch develop
git pull
git switch -c feature/user-search
# … work …
git switch develop
git merge --no-ff feature/user-search
git branch -d feature/user-search

--no-ff is conventional in Git Flow. It forces a merge commit even when a fast-forward is possible, so the branch remains visible as a unit in the history. Fast-Forward vs Three-Way Merge explains what that flag actually does.

When develop contains enough for a release, it is branched.

Terminal window
git switch develop
git switch -c release/1.1

From this point:

  • develop is free again. Work on the following release continues immediately.
  • The release branch accepts only stabilisation. Bug fixes, version bumps, changelog entries — no new features.
  • When it is ready, it merges into main and is tagged.
Terminal window
git switch main
git merge --no-ff release/1.1
git tag -a v1.1.0 -m "Release 1.1.0"
git switch develop
git merge --no-ff release/1.1
git branch -d release/1.1

A production defect needs fixing without shipping whatever develop currently contains. Hotfix branches start from main:

Terminal window
git switch main
git switch -c hotfix/1.1.1
# … fix …
git switch main
git merge --no-ff hotfix/1.1.1
git tag -a v1.1.1 -m "Hotfix 1.1.1"
git switch develop
git merge --no-ff hotfix/1.1.1
git branch -d hotfix/1.1.1

The same two-target rule applies. If a release branch is currently open, the hotfix normally merges into that instead of develop, so it is not lost when the release lands.

The original model mentions long-lived support branches for maintaining older versions — a support/1.x branch that receives backported fixes long after 2.0 ships. They are optional and only relevant if you genuinely support multiple released versions concurrently. If you do, this is one of the strongest arguments for a Git Flow-shaped model.

A concrete pass through the model, for a team shipping version 1.1 of an installed product.

  1. Feature work accumulates on develop. Three developers land feature/user-search, feature/export-csv and fix/timezone-parsing over two weeks. Each branched from develop and merged back with --no-ff.

  2. Scope is frozen. The team decides 1.1 is those three changes.

    Terminal window
    git switch develop && git pull
    git switch -c release/1.1
    git push -u origin release/1.1

    develop is immediately available again — work on 1.2 starts the same afternoon.

  3. Stabilisation. QA tests the release branch. Two defects are found and fixed on the release branch, not on develop:

    Terminal window
    git switch release/1.1
    git commit -am "Fix CSV export encoding on Windows"

    A release candidate can be tagged from here if the process calls for one:

    Terminal window
    git tag -a v1.1.0-rc.1 -m "Release candidate 1"
  4. Ship. The release branch merges into main and is tagged:

    Terminal window
    git switch main && git pull
    git merge --no-ff release/1.1
    git tag -a v1.1.0 -m "Release 1.1.0"
    git push origin main --follow-tags
  5. Merge back. The two stabilisation fixes must reach develop:

    Terminal window
    git switch develop
    git merge --no-ff release/1.1
    git push
  6. Clean up.

    Terminal window
    git branch -d release/1.1
    git push origin --delete release/1.1
  7. A hotfix, a week later. A customer reports a crash in 1.1.0. develop already contains unfinished 1.2 work, so the fix cannot come from there:

    Terminal window
    git switch main
    git switch -c hotfix/1.1.1
    git commit -am "Fix crash when the config file is empty"
    git switch main && git merge --no-ff hotfix/1.1.1
    git tag -a v1.1.1 -m "Hotfix 1.1.1"
    git switch develop && git merge --no-ff hotfix/1.1.1
    git branch -d hotfix/1.1.1

Step 7 is the scenario Git Flow exists for. With a single-branch model, shipping that fix means either shipping everything else currently on the mainline, or improvising a branch from the release tag — which is Git Flow’s hotfix branch, arrived at under pressure.

The model predates continuous delivery, and the interaction is where most modern friction appears.

Which branch deploys where? A typical mapping:

BranchEnvironment
developDevelopment or integration environment
release/*Staging or QA environment
main (tagged)Production

That is workable, but note what it implies: production deployments are driven by tags on main, not by merges. Your pipeline needs to trigger on tags, and “deploy the latest main” is not sufficient because main may have received a merge that is not yet the tagged release.

What runs on each branch? Feature branches and develop need the full test suite — that is where regressions are cheapest to catch. Release branches need the full suite plus whatever slower checks you skip elsewhere: performance tests, security scans, packaging verification.

The double merge is a pipeline concern. Because release and hotfix branches merge into two targets, CI should verify both landed. The check is whether the release branch tip is reachable from develop:

Terminal window
git merge-base --is-ancestor release/1.1 develop \
&& echo "stabilisation fixes reached develop" \
|| echo "MISSING: release/1.1 was not merged back"

Long-lived branches degrade CI signal. If develop and main diverge for weeks, a green build on one says little about the other. This is the same divergence cost as before, appearing as reduced confidence rather than as conflicts.

Parallel version support. Maintaining 1.x while developing 2.x has a defined home. Few other models address this at all.

Stabilisation without blocking. A release can be frozen and hardened while feature work continues on develop.

A clean production history. main is a list of releases. git log main is a changelog.

Explicit hotfix path. “Production is broken and develop is not shippable” has a documented answer rather than an improvised one.

Well known. The vocabulary is widely understood, which has real onboarding value.

Divergence, by design. develop and main are permanently different, and feature branches diverge from a develop that is itself diverging. The model builds in exactly the thing that makes integration expensive.

Two integration targets. Every change raises the question of which branch it belongs on. Answering it wrongly is easy and the consequences surface later.

Merge bookkeeping. Release and hotfix branches merging into two places is genuine ongoing work, and skipping it silently loses fixes.

A poor fit for continuous delivery. If you deploy several times a day, “main contains only released code” and a release-branch stabilisation phase describe a process you are not running.

Ceremony without benefit. A team that deploys from develop, never uses release branches, and supports exactly one version is maintaining two long-lived branches for nothing.

Slower feedback. A change reaches production through more stages, each adding latency between writing code and learning whether it works.

Reasonable fits:

  • Versioned software customers install. Desktop applications, mobile apps with review cycles, libraries, firmware, on-premise products.
  • Several supported versions at once. If 2.1, 2.0 and 1.9 all receive fixes, you need somewhere for each to live.
  • A genuine stabilisation period. Release candidates, manual QA cycles, external certification.
  • Regulated environments. Where an auditable, deliberately staged path to production is required.
  • Scheduled releases. Shipping monthly on a date is a different problem from shipping on merge.

Poor fits:

  • Continuously deployed web services. Use GitHub Flow or trunk-based development.
  • One version in production, ever. develop earns nothing.
  • Small teams. The coordination overhead dominates.
  • Strong automated testing and fast rollback. These substitute for the stabilisation phase.

Git Flow’s release branches are temporary; the durable record of a release is the tag on main.

Terminal window
git tag -a v1.1.0 -m "Release 1.1.0"
git push origin main --follow-tags

Use annotated tags (-a) rather than lightweight ones. An annotated tag is a real object carrying a tagger, a date and a message, and it can be signed — so it records who released what and when. Git Objects Explained covers the difference.

--follow-tags pushes annotated tags that point at commits being pushed, which avoids the common mistake of pushing main and forgetting the tag that names the release. Once tagged, the release branch has done its job and can be deleted — the tag is what anyone needs to reconstruct that version.

There is a widely used git-flow command-line extension that wraps the model in commands such as git flow feature start and git flow release finish. It is a convenience layer over ordinary Git operations, not a Git feature.

It removes the bookkeeping errors — particularly the double merge — which is a real benefit. It also hides what is happening, so people learn the commands without the model. If you adopt it, learn the underlying operations first; when something goes wrong you will be debugging Git, not the wrapper.

Most teams that benefit from Git Flow benefit from part of it. The pieces are separable:

  • Release branches without develop. Branch release/1.4 from main, stabilise, tag, merge back. This gives you a stabilisation window without a second permanent integration branch. Often the single most useful piece.
  • Hotfix discipline without the rest. Branch from the released tag, fix, tag, merge forward.
  • Support branches only when needed. Create support/1.x the day you actually have to maintain 1.x.

Taking the piece that solves your problem and leaving the rest is usually better than adopting the whole model or rejecting it wholesale.

Git Flow separates “what we are building” from “what we have shipped”.

develop is the first. main is the second. Release branches are the airlock between them, and hotfix branches are the door that bypasses the airlock when production is on fire.

If your team has no meaningful distinction between those two states — because what you build is shipped within the hour — then the model is separating things that are not separate, and the machinery has nothing to do.

  • Git Flow uses two permanent branches, main and develop, plus feature, release and hotfix branches.
  • main contains only released code; develop is the integration branch for the next release.
  • Release and hotfix branches must merge into two targets, and forgetting the second loses fixes.
  • It was designed for versioned software with parallel maintenance, not continuous delivery.
  • Its costs are built-in divergence, two integration targets and ongoing merge bookkeeping.
  • Individual pieces — release branches, hotfix discipline — are useful independently of the whole model.

Build the shape in a disposable repository to see the double merge concretely.

  1. Create a repository, commit, and tag it v1.0.0.
  2. Create develop from main. Commit two changes on develop.
  3. Create release/1.1 from develop. Commit a fix on it — this is stabilisation work.
  4. Merge release/1.1 into main with --no-ff and tag v1.1.0.
  5. Before merging back, run git log --oneline develop and check whether your fix is there.
  6. Merge release/1.1 into develop too, then check again.
  7. Run git log --oneline --graph --all and trace both merge paths.

Step 5 is the point of the exercise: the fix is not on develop yet. In a real repository that missing merge means the next release ships without it.

Trunk-based development goes in the opposite direction: fewer branches, shorter lifetimes, and a heavy reliance on automation.