Senior 10 min · March 06, 2026
Git Workflows — GitFlow

GitFlow Hotfix Not Merged to Develop — Bug Returns

Payment gateway null-pointer crash fixed in v1.0.1 reappeared in v1.1.0 because hotfix was not merged to develop.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • GitFlow uses five branch types: main, develop, feature, release, hotfix
  • Feature branches isolate work; release branches stabilise before deploy
  • Hotfix branches branch from main, merge back to both main and develop
  • Merge conflicts spike when feature branches live longer than a sprint
  • Teams forget to back-port hotfixes into develop — the #1 production bug reintroduced
✦ Definition~90s read
What is Git Workflows?

GitFlow is a Git branching model designed to enforce strict separation between ongoing development, feature work, releases, and emergency production fixes. It was popularized by Vincent Driessen in 2010 and is built around five permanent branches: main (production-ready code), develop (integration branch for features), plus three supporting branch types — feature/, release/, and hotfix/*.

Imagine a busy restaurant kitchen.

The model’s core purpose is to provide a predictable, auditable path for code to move from development to production, with explicit stabilization windows for releases and a dedicated mechanism for patching live systems without pulling in incomplete work. It solves the problem of coordinating multiple developers and parallel workstreams in a way that trunk-based development (TBD) does not, but at the cost of increased complexity and merge overhead.

The critical failure mode GitFlow addresses — and often introduces — is the hotfix-not-merged-to-develop bug. When a hotfix branch is created from main to patch a production issue, it must be merged back into both main and develop. If the merge to develop is skipped or forgotten, the fix is effectively lost in the next release cycle, causing the same bug to reappear.

This is not a theoretical edge case; it’s a common pitfall in teams using GitFlow without automation or strict process enforcement. Tools like GitHub Actions, GitLab CI, or pre-receive hooks can enforce this merge, but many teams rely on manual discipline and fail.

GitFlow is best suited for projects with scheduled releases, multiple parallel features, and a need for a clear audit trail — think enterprise SaaS with monthly releases or open-source projects with long-lived feature branches. It is a poor fit for continuous deployment, microservices with independent release cycles, or small teams where the overhead of branch management outweighs the benefits.

In those cases, trunk-based development with short-lived feature flags is simpler and faster. The choice between GitFlow and TBD is ultimately about release cadence and risk tolerance: GitFlow buys you isolation and stabilization at the cost of merge complexity; TBD buys you speed and simplicity at the cost of requiring robust feature flags and disciplined commits.

Plain-English First

Imagine a busy restaurant kitchen. There's a prep area where chefs experiment with new dishes, a staging area where meals are plated and checked before leaving the kitchen, and the pass where only perfect plates go out to customers. GitFlow is exactly that system — but for your codebase. Every new feature gets its own prep space, nothing reaches production until it's been checked in staging, and if something goes wrong with a dish already at the table, there's a dedicated rescue process that doesn't halt the whole kitchen.

Most teams that struggle with deployments aren't bad at writing code — they're bad at managing change. Features half-done get pushed to production. A hotfix for a critical bug accidentally ships an unfinished feature alongside it. Two developers overwrite each other's work on the same branch. These aren't skill problems; they're workflow problems. And in a world where a single bad deploy can cost thousands in downtime or lost revenue, having a repeatable, structured branching strategy isn't optional — it's essential.

GitFlow, introduced by Vincent Driessen in 2010, solves exactly this chaos by giving every type of change its own lane on the road. New features live in isolation until they're ready. Releases get a dedicated stabilisation window before they go live. Emergency fixes can be applied to production without dragging half-finished work along for the ride. The model is opinionated — and that's the point. Opinions eliminate ambiguity, and ambiguity is what causes 2am incidents.

By the end of this article you'll understand not just the five branch types in GitFlow and how they relate to each other, but — more importantly — when GitFlow is the right tool and when it'll slow you down. You'll be able to set up a full GitFlow project from scratch, navigate a real-world feature-to-release cycle, apply an emergency hotfix without breaking anything, and walk into an engineering interview and confidently explain the trade-offs of GitFlow versus trunk-based development.

Why GitFlow Hotfixes Break Develop — And How to Prevent It

GitFlow is a branching model that structures releases around two long-lived branches: main (production) and develop (integration). The core mechanic is that all feature branches branch off develop, and all releases merge into both main and develop. Hotfixes branch off main to patch production, but must also merge back into develop — otherwise the fix is lost in the next release.

In practice, the hotfix branch is created from main, the fix is applied, then merged into main and tagged. The critical second merge into develop is often forgotten. When that happens, the bug reappears as soon as the next release is cut from develop. This is not a GitFlow flaw — it's a process failure. The model works only if every hotfix is merged into both branches.

Use GitFlow when you need strict release isolation and support multiple concurrent versions. It shines in regulated environments or when you must patch production without pulling in unfinished features. But it adds overhead: every hotfix requires two merges. Automate the second merge with a CI/CD pipeline or a merge-bot to eliminate the human error.

Hotfix Merge Trap
Merging a hotfix only to main is a half-fix. The bug will return on the next release from develop.
Production Insight
Team hotfixed a critical null pointer in production but forgot to merge into develop. Two weeks later, the same NPE crashed the staging environment during a release candidate test.
Symptom: a bug that was 'fixed' in production reappears in the next release candidate with no code changes.
Rule: every hotfix merge to main must be immediately followed by a merge to develop — automate this as a single atomic step.
Key Takeaway
GitFlow is a release-isolation model, not a feature-branch model — treat it as such.
A hotfix not merged to develop is a time bomb that detonates on the next release.
Automate the hotfix merge to develop — never rely on human memory for a process-critical step.
GitFlow Hotfix Not Merged to Develop — Bug Returns THECODEFORGE.IO GitFlow Hotfix Not Merged to Develop — Bug Returns Flow of hotfix branch and the common pitfall of missing merge back Production Bug Detected Emergency fix needed on main branch Hotfix Branch Created Branched from main, not develop Hotfix Merged to Main Fix deployed to production Hotfix Not Merged to Develop Develop still contains the bug Bug Returns on Next Release Develop merged to main reintroduces bug ⚠ Forgetting to merge hotfix back to develop Always merge hotfix into develop and main THECODEFORGE.IO
thecodeforge.io
GitFlow Hotfix Not Merged to Develop — Bug Returns
Git Workflows Gitflow

The Five-Branch Architecture — Why Each Branch Exists

GitFlow uses five branch types, and understanding the purpose of each is more important than memorising their names. Two branches are permanent and never get deleted: main and develop. Three are temporary and get cleaned up after use: feature/, release/, and hotfix/*.

main represents what's in production right now. Every commit on main is a tagged, deployed release. Nothing ever gets committed directly to main — it only ever receives merges from release or hotfix branches. Think of it as the restaurant's dining room: only finished, approved dishes arrive here.

develop is the integration branch — the staging kitchen. It holds the latest completed work that's been approved for the next release. Feature branches are cut from develop and merge back into develop when done. It's always ahead of main, and it might be slightly unstable on any given day, but it's never a mess.

Temporary branches are where the actual work happens. A feature/user-authentication branch gives one team or developer complete isolation. A release/2.4.0 branch freezes the feature set and allows only bug fixes before shipping. A hotfix/fix-payment-gateway-null-crash branch lets you patch production without touching any in-progress work. Every branch type has a strict source and a strict destination — that structure is what prevents the chaos described in the introduction.

gitflow-project-setup.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# Full GitFlow project setup from scratch — no plugins required.
# This uses plain Git commands so you understand what's happening
# under the hood before you reach for the git-flow CLI extension.
# ─────────────────────────────────────────────────────────────

# Step 1: Create a new repo and establish the permanent branches.
git init ecommerce-platform
cd ecommerce-platform

# Create an initial commit so 'main' has a history to branch from.
echo "# E-Commerce Platform" > README.md
git add README.md
git commit -m "chore: initial project setup"

# Rename the default branch to 'main' if your Git version defaults to 'master'.
git branch -M main

# Step 2: Create the 'develop' branch from 'main'.
# 'develop' is the long-lived integration branch — all feature work lands here.
git checkout -b develop

# At this point we have our two permanent branches.
git branch
# * develop
#   main

# Step 3: Start a new feature branch.
# Feature branches are ALWAYS cut from 'develop', never from 'main'.
# The 'feature/' prefix is a convention, not a Git requirement — but follow it.
git checkout -b feature/user-authentication develop

# Simulate some development work on the feature.
echo "def authenticate_user(email, password): pass" > auth.py
git add auth.py
git commit -m "feat(auth): add user authentication scaffold"

echo "def validate_token(token): pass" >> auth.py
git add auth.py
git commit -m "feat(auth): add JWT token validation"

# Step 4: Merge the completed feature back into 'develop'.
# --no-ff (no fast-forward) forces a merge commit, which preserves the
# history of the feature branch in the graph. Without this, the feature's
# commits get absorbed into develop's linear history and you lose visibility.
git checkout develop
git merge --no-ff feature/user-authentication -m "merge: integrate user authentication feature into develop"

# Clean up the local feature branch — it's served its purpose.
git branch -d feature/user-authentication

# Step 5: Cut a release branch when develop is ready to ship.
# Release branches are cut from 'develop'. Only bug fixes, docs updates,
# and release-preparation commits should land here. No new features.
git checkout -b release/1.0.0 develop

# Simulate a pre-release bug fix found during QA.
echo "# auth module v1.0.0" >> auth.py
git add auth.py
git commit -m "fix(auth): handle empty password edge case before release"

# Step 6: Finalise the release — merge into BOTH main AND develop.
# main gets the release so production is updated.
git checkout main
git merge --no-ff release/1.0.0 -m "release: ship v1.0.0 to production"
git tag -a v1.0.0 -m "Version 1.0.0 — initial public release"

# develop must also receive the release branch changes (e.g., the QA bug fix)
# so that the fix isn't lost in future work.
git checkout develop
git merge --no-ff release/1.0.0 -m "merge: back-port release/1.0.0 fixes into develop"

# Clean up the release branch.
git branch -d release/1.0.0

echo "GitFlow project structure established successfully."
Output
Initialized empty Git repository in /projects/ecommerce-platform/.git/
[main (root-commit) 4a1b2c3] chore: initial project setup
1 file changed, 1 insertion(+)
Switched to a new branch 'develop'
* develop
main
Switched to a new branch 'feature/user-authentication'
[feature/user-authentication 7d3e4f5] feat(auth): add user authentication scaffold
[feature/user-authentication 9a1b0c2] feat(auth): add JWT token validation
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch feature/user-authentication (was 9a1b0c2).
Switched to a new branch 'release/1.0.0'
[release/1.0.0 2f8d1e9] fix(auth): handle empty password edge case before release
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch release/1.0.0 (was 2f8d1e9).
GitFlow project structure established successfully.
Watch Out: Always Use --no-ff When Merging Feature Branches
If you merge a feature branch into develop with a fast-forward merge (the default), Git will linearise the commits and you'll lose all visual evidence that those commits belonged to a feature. Six months later, your git log --graph will be a straight line and a git bisect or blame session becomes needlessly painful. Always pass --no-ff to preserve the merge commit and the branch context in your history.
Production Insight
Teams that skip --no-ff lose the ability to easily revert a feature. Without a merge commit, reverting a set of feature commits requires cherry-picking in reverse — error-prone.
A straight-line history hides which releases contained which features.
Rule: enforce --no-ff in repo config: git config merge.ff false
Key Takeaway
Feature branches merge with --no-ff to preserve branch context.
Without it, the merge is invisible — reverts and blame become guesswork.
Always enforce no-ff in your team's Git config or CI.

Handling Emergency Production Bugs With Hotfix Branches

Here's the scenario no one wants but every team eventually faces: it's Thursday afternoon, version 1.0.0 is live, and a critical payment processing bug is crashing checkouts for 15% of users. Meanwhile, develop already has three half-finished features merged into it that absolutely cannot ship with this emergency fix.

This is exactly why hotfix branches exist — and why their source branch being main (not develop) is so deliberate. By branching from main, you get a clean copy of exactly what's in production, untainted by anything on develop. You fix only the bug, merge back to main, tag a new patch version, and then — critically — also merge back to develop so the fix isn't lost.

That last step trips people up constantly. If you only merge the hotfix into main and forget develop, the bug you just fixed will silently re-appear in your next scheduled release when develop gets merged to main. It's one of the most insidious mistakes in GitFlow practice.

The hotfix branch also signals urgency to your team through its name. When someone sees hotfix/fix-payment-null-pointer, everyone on the team immediately knows this isn't routine work — it's a production incident, and it has priority.

gitflow-hotfix-cycle.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# GitFlow Hotfix CycleEmergency production bug repair.
# Scenario: v1.0.0 is live. A null pointer crash is breaking
# the payment gateway for a subset of users. develop has
# unfinished work that cannot ship. We must fix prod NOW.
# ─────────────────────────────────────────────────────────────

# Step 1: Branch from 'main'NOT from 'develop'.
# main = what's in production. develop = what's coming next.
# We want a clean snapshot of prod to work from.
git checkout main
git checkout -b hotfix/fix-payment-gateway-null-crash

# Step 2: Apply the targeted fix.
# In a real scenario this is where your developer fixes the bug.
cat > payment_gateway.py << 'EOF'
def process_payment(order_id, payment_details):
    # HOTFIX v1.0.1: Added None guard — payment_details was not
    # validated upstream, causing a NullPointerError on orders
    # created via the mobile API which omits the billing_zip field.
    if payment_details is None:
        raise ValueError("payment_details cannot be None — check mobile API payload")
    
    billing_zip = payment_details.get("billing_zip", "00000")  # safe default
    return {"status": "approved", "order_id": order_id, "zip": billing_zip}
EOF

git add payment_gateway.py
git commit -m "fix(payments): guard against None payment_details from mobile API"

# Step 3: Update the version number in the project metadata.
# This is important — production tags must be unique and meaningful.
echo "1.0.1" > VERSION
git add VERSION
git commit -m "chore(release): bump version to 1.0.1 for hotfix"

# Step 4: Merge hotfix into 'main' and tag the new production version.
git checkout main
git merge --no-ff hotfix/fix-payment-gateway-null-crash \
  -m "hotfix: merge payment gateway null crash fix into main"
git tag -a v1.0.1 -m "Version 1.0.1 — Emergency fix for payment gateway null crash"

# Step 5: CRITICAL — merge hotfix into 'develop' too.
# If you skip this, the bug will resurface in v1.1.0 when develop
# gets merged to main. This step is the one most teams forget.
git checkout develop
git merge --no-ff hotfix/fix-payment-gateway-null-crash \
  -m "hotfix: back-port payment gateway null crash fix into develop"

# Step 6: Delete the hotfix branch — it has served its purpose.
git branch -d hotfix/fix-payment-gateway-null-crash

# Verify the tag exists on main
git checkout main
git log --oneline --graph -5

echo "Hotfix v1.0.1 shipped and back-ported to develop successfully."
Output
Switched to branch 'main'
Switched to a new branch 'hotfix/fix-payment-gateway-null-crash'
[hotfix/fix-payment-gateway-null-crash 3c7f2a1] fix(payments): guard against None payment_details from mobile API
[hotfix/fix-payment-gateway-null-crash 8b4e9d0] chore(release): bump version to 1.0.1 for hotfix
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch hotfix/fix-payment-gateway-null-crash (was 8b4e9d0).
Switched to branch 'main'
* 5e1a3b2 (HEAD -> main, tag: v1.0.1) hotfix: merge payment gateway null crash fix into main
|\
| * 8b4e9d0 chore(release): bump version to 1.0.1 for hotfix
| * 3c7f2a1 fix(payments): guard against None payment_details from mobile API
|/
* 4d9c8f1 (tag: v1.0.0) release: ship v1.0.0 to production
Hotfix v1.0.1 shipped and back-ported to develop successfully.
Pro Tip: Use Semantic Versioning Tags Consistently
GitFlow and semantic versioning (MAJOR.MINOR.PATCH) are natural partners. Hotfixes bump PATCH (1.0.0 → 1.0.1). Feature releases bump MINOR (1.0.0 → 1.1.0). Breaking changes bump MAJOR (1.0.0 → 2.0.0). Enforcing this in your team means anyone reading git tag instantly knows the risk level of each release without reading a changelog.
Production Insight
A single missed back-port caused a $2M payment gateway outage for a fintech team — same bug hit production twice, separated by three weeks.
The fix cost 8 hours of debugging the second time because no one remembered the hotfix.
Rule: after every hotfix finish, verify git log develop --oneline | grep <hotfix-sha> and add a CI check.
Key Takeaway
Hotfixes must merge into both main and develop.
Skipping the develop merge is the #1 GitFlow mistake.
Automate the verification — don't trust team memory.

Release Management in GitFlow — The Stabilisation Window

A release branch (release/1.1.0) is cut from develop when the team agrees that the current set of features is complete enough for the next version. At that moment, no new features are allowed. Only bug fixes, version bumps, and release-related documentation changes land on this branch.

This freeze is the stabilisation window. It gives QA a fixed target; they know the feature set won't expand. It gives the ops team time to prepare deployment notes. And it gives developers time to fix the inevitable edge cases that surface when features interact for the first time in a release context.

When the release branch is stable, it merges into main with a tag, and — critically — also back into develop. The back-merge ensures that any bug fixes applied during stabilisation are carried forward. Without it, those fixes get lost in the next release cycle.

The stabilisation window is also where you handle versioning. Bump the version number in your semantic versioning scheme — MINOR for a feature release, MAJOR for breaking changes. This is the last chance for any changes before the release hits production.

gitflow-release-cycle.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# GitFlow Release Cycle — from develop to production.
# Assume develop has completed features. We now freeze for a
# release candidate.
# ─────────────────────────────────────────────────────────────

# Step 1: Cut release branch from develop.
git checkout develop
git pull origin develop
git checkout -b release/1.1.0 develop

# Step 2: Only bug fixes and version bumps from here on.
# Example: Fix a minor UI alignment issue found in QA.
echo "Version: 1.1.0" > VERSION
git add VERSION
git commit -m "chore(release): bump version to 1.1.0"

# Fix a bug: cart total rounding error
echo "def calculate_total(items): return round(sum(i.price for i in items), 2)" >> cart.py
git add cart.py
git commit -m "fix(cart): correct floating-point rounding in total calculation"

# Step 3: After QA approval, merge release into main and develop.
git checkout main
git merge --no-ff release/1.1.0 -m "release: ship v1.1.0 to production"
git tag -a v1.1.0 -m "Version 1.1.0 — Cart fixes and performance improvements"

git checkout develop
git merge --no-ff release/1.1.0 -m "merge: back-port release/1.1.0 fixes into develop"

# Step 4: Delete the release branch.
git branch -d release/1.1.0

echo "Release v1.1.0 shipped and back-ported successfully."
Output
Switched to branch 'develop'
Already up to date.
Switched to a new branch 'release/1.1.0'
[release/1.1.0 9a2b3c4] chore(release): bump version to 1.1.0
[release/1.1.0 5d6e7f8] fix(cart): correct floating-point rounding in total calculation
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch release/1.1.0 (was 5d6e7f8).
Done.
Use Release Notes in Merge Messages
When merging a release branch into main, the merge message is often the first thing an operator sees. Include a brief changelog: release: v1.1.0 — Cart fixes, rounding correction, version bump. This makes git log --first-parent main a readable release history.
Production Insight
A team skipped the release back-merge to develop once. The QA bug fix from the release was lost. Three sprints later, the same bug reappeared, costing 6 hours of rework.
Double-merge is not overhead — it's insurance.
Rule: enforce double-merge via script in release automation.
Key Takeaway
Release branches freeze features, stabilise with bug fixes, then merge to both main and develop.
The back-merge to develop carries forward fixes.
Without it, every release fix must be reapplied later.

GitFlow vs Trunk-Based Development — Choosing the Right Workflow

GitFlow is a powerful model, but it isn't the right model for every team or every product — and knowing when not to use it is as important as knowing how to use it.

GitFlow shines when your team ships scheduled, versioned releases — think desktop software, mobile apps, open-source libraries, or enterprise SaaS with quarterly release windows. The structured branch lifecycle gives you clear separation between what's done, what's being stabilised, and what's in progress. QA teams love it because there's an explicit release branch to test against. Ops teams love it because main is always a known-good, tagged state.

Trunk-based development, by contrast, is better for teams deploying multiple times a day. Everyone commits to a single main branch (or very short-lived feature branches). Feature flags control what's visible in production. The overhead of maintaining five branch types would actively slow these teams down.

The honest answer: if your CI/CD pipeline deploys to production on every merge and your team has mature feature flagging, trunk-based is likely faster for you. If you have a QA cycle, multiple environments, compliance requirements, or a public API with versioned releases, GitFlow's structure pays for itself many times over.

gitflow-cli-quickstart.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# Using the git-flow CLI extension — the faster way once you
# understand the underlying model. Install first:
#   macOS:  brew install git-flow-avh
#   Ubuntu: apt-get install git-flow
#   Windows: included in Git for Windows
# ─────────────────────────────────────────────────────────────

# Initialise GitFlow in an existing repo.
# This sets up the branch naming conventions in .git/config.
# Accept all the defaults by pressing Enter through the prompts,
# or use -d flag for fully non-interactive default setup.
git flow init -d

# ── FEATURE WORKFLOW ──────────────────────────────────────────
# Start a feature — automatically branches from 'develop'
git flow feature start shopping-cart-persistence

# ... do your work, make commits ...
echo "def save_cart(user_id, cart_items): pass" > cart.py
git add cart.py
git commit -m "feat(cart): implement session-based cart persistence"

# Finish the feature — merges into develop with --no-ff, deletes branch
git flow feature finish shopping-cart-persistence
# Output: Switched to branch 'develop'
#         Merge made by the 'ort' strategy.
#         Deleted branch feature/shopping-cart-persistence

# ── RELEASE WORKFLOW ──────────────────────────────────────────
# Start a release branch from develop's current state
git flow release start 1.1.0

# Apply only bug fixes and release prep commits here
echo "1.1.0" > VERSION
git add VERSION
git commit -m "chore(release): bump version to 1.1.0"

# Finish the release:
# - merges into main AND develop
# - creates a tag automatically
# - deletes the release branch
# -m sets the tag message without opening an editor
git flow release finish -m "Version 1.1.0 — Shopping cart persistence" 1.1.0

# ── HOTFIX WORKFLOW ───────────────────────────────────────────
# Start a hotfix from main (production)
git flow hotfix start fix-cart-total-rounding-error

# Fix the bug
echo "def calculate_total(items): return round(sum(i.price for i in items), 2)" >> cart.py
git add cart.py
git commit -m "fix(cart): correct floating-point rounding in total calculation"

# Finish hotfix — merges to main AND develop, tags, deletes branch
git flow hotfix finish -m "Hotfix: cart total rounding" fix-cart-total-rounding-error

# Verify your tag history
git tag --list
echo "All workflows complete."
Output
Using default branch names.
Summary of actions:
- A new branch 'develop' was created
- You are now on branch 'develop'
Switched to a new branch 'feature/shopping-cart-persistence'
[feature/shopping-cart-persistence 1a2b3c4] feat(cart): implement session-based cart persistence
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch feature/shopping-cart-persistence (was 1a2b3c4).
Switched to a new branch 'release/1.1.0'
[release/1.1.0 5d6e7f8] chore(release): bump version to 1.1.0
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch release/1.1.0 (was 5d6e7f8).
Switched to a new branch 'hotfix/fix-cart-total-rounding-error'
[hotfix/fix-cart-total-rounding-error 9g0h1i2] fix(cart): correct floating-point rounding in total calculation
Switched to branch 'main'
Merge made by the 'ort' strategy.
Switched to branch 'develop'
Merge made by the 'ort' strategy.
Deleted branch hotfix/fix-cart-total-rounding-error (was 9g0h1i2).
v1.0.0
v1.0.1
v1.1.0
All workflows complete.
Interview Gold: Know When to Argue Against GitFlow
Interviewers at mature companies don't just want to know if you can follow GitFlow — they want to know if you understand its trade-offs. Be ready to say: 'GitFlow adds overhead that pays off for versioned releases but can slow down teams doing continuous deployment. I'd recommend trunk-based development with feature flags for a team shipping 10+ times a day.' That kind of nuance separates senior candidates from those who just memorised the diagram.
Production Insight
A startup tried GitFlow for a greenfield SaaS deploying three times a day. The overhead of managing release branches and hotfixes added two hours of ceremony each week. They switched to trunk-based and cut regression incidents by 40%.
Context matters more than dogma.
Rule: match your branching model to your deployment frequency.
Key Takeaway
GitFlow suits scheduled, versioned releases.
Trunk-based suits continuous deployment with feature flags.
The wrong workflow adds cost; the right one adds velocity.

Common GitFlow Pitfalls and How to Avoid Them

Even experienced teams fall into these traps. The three most common:

  1. Forgetting to back-port hotfixes to develop. This is the single most dangerous mistake — it causes duplicated debugging and production incidents repeated. The fix is a post-merge hook that checks the commit hash in both branches.
  2. Committing new features to a release branch. The release branch is a freeze zone. When developers sneak in 'one small feature,' QA scope explodes and the release slips. The correction is strict pull request templates that reject feature commits on release branches.
  3. Using fast-forward merges. Without --no-ff, the merge commit is skipped, and the branch structure disappears from the graph. Six months later, you can't tell which commits belonged to which feature. Bisecting a regression becomes guesswork. Enforce --no-ff in your Git config and CI.

These pitfalls share a root cause: the team treats GitFlow as a tool to be executed rather than a set of rules to be followed religiously. The structure only works if everyone commits to the structure.

gitflow-pitfall-hooks.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# Post-merge hook for develop to verify hotfix back-port.
# Place this in .git/hooks/post-merge on the develop branch.
# ─────────────────────────────────────────────────────────────

REF=$1
OLDREV=$2
NEWREV=$3

# Only run on develop branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" != "develop" ]; then
    exit 0
fi

# Check if this merge was a hotfix (message contains "hotfix")
MERGE_MSG=$(git log -1 --pretty=%B)
if echo "$MERGE_MSG" | grep -qi "hotfix"; then
    echo "⚠️  Hotfix merge detected. Ensure the hotfix commit also exists in main."
    echo "   Run: git log main --oneline | grep $(git rev-parse HEAD)"
fi

exit 0
Output
No output if branch is not develop.
If on develop and merge message contains 'hotfix', prints warning.
Automate the Safety Nets — Don't Rely on Team Memory
The most reliable way to enforce GitFlow rules is through CI. Add a job that runs after every merge to main that checks if the same commit exists on develop. If not, fail the pipeline and require a back-merge. Human reminders erode over time, but automation never forgets.
Production Insight
A team with 15 developers lost 3 days per quarter to bugs reintroduced because hotfix back-ports were missed. After adding a CI check, they had zero recurrence in 18 months.
Measurement: 3 days per quarter → 0.
Rule: automate the rules — your team's focus is more valuable elsewhere.
Key Takeaway
Common pitfalls: missed back-port, feature creep on release branches, fast-forward merges.
Prevent them with hooks, CI checks, and strict pull request templates.
The workflow is only as reliable as the enforcement.

Initializing GitFlow Without the Training Wheels

Don't install the GitFlow extension. Seriously. That CLI sugar-coats the branching model and makes you forget which branches merge where. If you can't type git merge --no-ff develop from memory, you're not ready for production.

The extension creates an illusion of simplicity. When you run git flow feature finish, it auto-merges and deletes the branch. That's fine until a hotfix breaks develop and you can't trace which merge introduced the conflict because the tool hid the mechanics.

Instead, initialize a bare-bones GitFlow structure manually. Start with main and develop as orphan branches. Set develop as the default. Tag your first commit on main as v0.0.0. That's it. You now control every merge, every branch, every failure. If a junior asks why, tell them: the only way to master a workflow is to bleed through the commands your tools abstract away.

InitGitFlowManually.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — devops tutorial

# Create orphan branches to wipe commit history contamination
git checkout --orphan main
git rm -rf .
echo "# Project" > README.md
git add .
git commit -m "chore: initial commit on main"
git tag v0.0.0

# Branch develop from main and push both
git checkout -b develop
git push origin main develop

# Configure default branch in remote (GitHub CLI example)
gh api repos/:owner/:repo -f default_branch=develop
Output
Switched to a new branch 'main'
[main (root-commit) a1b2c3d] chore: initial commit on main
1 file changed, 1 insertion(+)
Switched to a new branch 'develop'
Remote: develop branch set as default
Production Trap:
Never initialize a repo with the git flow init wizard. It sets main as the default branch, which encourages accidental direct pushes to production. Manually lock main with a protected branch rule before the first commit.
Key Takeaway
Master the raw git commands first. The extension hides the failure modes you'll debug at 2 AM.

Hotfix Branches — Where GitFlow Meets Reality

Your pager goes off at 3:14 AM. Payment gateway is returning 500s in production. A hotfix branch is the only GitFlow construct that starts from main and merges into both main AND develop. This dual-merge creates the most common disaster: merge conflicts on develop when the hotfix changes code that's been refactored in a release branch.

Here's the fix. Create the hotfix off main, apply the patch, and test it in isolation. Before merging to main, cherry-pick the fix commit onto a temporary branch off develop. Resolve conflicts there. Then merge the hotfix to main and rebase develop onto the resolved branch. This keeps the history linear and prevents stale conflicts from poisoning the next release.

Never trust git flow hotfix finish. It assumes the hotfix codebase is identical to develop. That's a statistical lie. Manual cherry-pick and rebase gives you surgical control. The extra five minutes saves you an hour of untangling merge hell.

HotfixEmergencyWorkflow.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — devops tutorial

# Step 1: Branch from main, apply fix
git checkout main
git checkout -b hotfix/payment-500
# ... apply fix, commit
git commit -m "fix: handle null response in payment callback"

# Step 2: Cherry-pick onto develop base, resolve conflicts
git checkout -b temp-resolve develop
git cherry-pick hotfix/payment-500
# resolve conflicts, then:
git commit -m "fix: apply hotfix to develop"

# Step 3: Merge hotfix to main, tag release
git checkout main
git merge --no-ff hotfix/payment-500 -m "hotfix: payment 500 error"
git tag -a v2.3.1 -m "hotfix payment emergency"

# Step 4: Rebase develop onto temp-resolve
git checkout develop
git rebase temp-resolve
git branch -D temp-resolve hotfix/payment-500
Output
hotfix/payment-500 created
Cherry-pick successful
1 conflict resolved manually
main updated to v2.3.1
develop rebased successfully
Senior Shortcut:
Automate the cherry-pick and rebase chain with a script. If your team hits hotfix conflicts more than once a quarter, the manual process wastes more time than the automation costs.
Key Takeaway
Hotfix branches need a cherry-pick bridge to develop. Never merge hotfix branches directly into develop without conflict resolution.

The Release Branch Stabilisation Window Your CI Pipeline Is Missing

Developers treat release branches like a staging environment — code gets pushed, tests run, then it's merged to main. That's cargo cult thinking. A release branch is a freeze zone. Only bug fixes, dependency patches, and documentation changes should land there. Feature additions get fast-rejected. Period.

Here's the CI hook that enforces this: configure your pipeline to fail any PR into a release branch that touches source files in more than one module. Use a diff check in the CI config. If git diff --name-only $PR_BASE...$PR_HEAD shows backend/ and frontend/ both changed, reject the merge. This forces surgical fixes and prevents scope creep.

Your stabilisation window should be three days max. Not two weeks while marketing picks a launch date. If a release takes longer than 72 hours to stabilise, your feature flags are broken or your develop branch is a landfill. Cut the window to 24 hours, learn to revert, and ship. Deadlines shrink to fit the time you give them.

ReleaseBranchCiEnforcer.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — devops tutorial

name: release-stabilization
on:
  pull_request:
    branches: ["release/*"]

jobs:
  check-scope:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Block multi-module changes
        run: |
          MODULES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | cut -d/ -f1 | sort -u | wc -l)
          if [ "$MODULES" -gt 1 ]; then
            echo "❌ Release branch changed $MODULES modules. Only one module per PR."
            exit 1
          fi
          echo "✅ Single module change — approved"
Output
❌ Release branch changed 3 modules. Only one module per PR.
Production Trap:
If you set the stabilisation window to more than 72 hours, you're using release branches as a crutch for a broken develop branch. Fix develop. Don't widen the window.
Key Takeaway
Release branches are a freeze, not a shelf. Cap the stabilisation window at 72 hours and enforce single-module changes in CI.

Best Practices for Using GitFlow — Stop Copying the Pain

GitFlow isn't a religion; it's a tool for coordinating releases across teams that can't afford to break master. Most teams cargo-cult the branching model and wonder why they bleed velocity. The WHY is simple: you're treating branches like they're free. They're not. Every merge is a cognitive tax.

Start with feature flags. They let you merge incomplete work into develop without breaking the build. Never merge a feature branch that hasn't been rebased onto the latest develop — stale branches create merge hell. Keep feature branches alive for no longer than two days. If it takes longer, you're building a snowflake; split the feature.

Hotfix branches bypass develop for a reason: they're emergencies. Don't use them for regular patches. That's what release branches are for. And never merge a hotfix directly into master without also merging into develop within the same day. Otherwise, you'll orphan the fix in your next release.

Finally, tag every release commit on master. If you can't git log --oneline v1.2.0 and see exactly what shipped, you've already lost.

gitflow-best-practices.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — devops tutorial

// Enforce feature branch age limits in CI
git:
  branch_policy:
    feature_max_days_open: 2
    require_rebase_on_develop: true
    hotfix_sync_to_develop_within_hours: 12

release:
  tags:
    must_match: '^v\d+\.\d+\.\d+$'
    enforce_annotation: true
Output
CI rejects any feature branch older than 48 hours or not rebased on develop.
Production Trap:
If your hotfix PR only targets master, you've just created a regression bomb. Always open a parallel PR to develop.
Key Takeaway
Merge discipline trumps branch structure. Rebase daily, flag everything, and never let a hotfix orphan develop.

Preparing and Releasing with GitFlow — The Stabilization Window You're Missing

Here's the dirty secret: most teams cut a release branch and immediately start fixing bugs on it. You're treating the release branch as a garbage bin. The WHY is that you haven't built a true stabilization window into your pipeline.

A release branch is not a playground. It's a quarantine zone. You create it from develop when the feature set is frozen. Then you stop all feature merges and only push bugfixes — directly to the release branch. No develop merges, no master backports. This creates a single point of truth for what will ship.

The magic happens next: you run your full integration suite on the release branch. If it passes, you merge to master and tag. If it fails, you fix it on the release branch, not develop. Once the release ships, merge the release branch back into develop to capture those fixes. Do this within 24 hours or your develop branch rots.

Without this window, you're gambling that master is stable. It isn't. A proper stabilization window shaves hours off production outages and turns a fire drill into a checklist.

release-stabilization.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — devops tutorial

// Example release branch workflow
jobs:
  prepare_release:
    steps:
      - git checkout -b release/1.4.0 develop
      - git push origin release/1.4.0
      # run full CI suite here — no feature merges allowed
      - if [ $? -eq 0 ]; then
          git checkout master
          git merge release/1.4.0 --no-ff
          git tag -a v1.4.0 -m "Release 1.4.0"
          git push origin master --tags
        fi
      - git checkout develop
      - git merge release/1.4.0 --no-ff
      - git branch -d release/1.4.0
Output
Release branch 1.4.0 merged to master and tagged. Develop synced within 24 hours.
Senior Shortcut:
Automate the release branch merge-back to develop. Manual syncs are the #1 cause of regressions after a release.
Key Takeaway
Freeze features when you cut the release branch. Fix bugs only there. Merge back to develop within 24 hours or lose control.
● Production incidentPOST-MORTEMseverity: high

Hotfix merged to main, but not to develop — bug reappears in next release

Symptom
After shipping release v1.1.0 (which contained features from develop), the same payment gateway null-pointer crash that was supposedly fixed in v1.0.1 returned. The hotfix commit was present on main but absent from develop's commit history.
Assumption
The developer assumed that merging the hotfix into main was sufficient. 'It's fixed in production — the rest will catch up.' The release branch was cut before the hotfix was back-ported.
Root cause
The hotfix was merged only into main. When the release branch v1.1.0 was created from develop, it lacked the hotfix changes. The release was then merged into main, overwriting the hotfix with old code. The fix was effectively lost.
Fix
Immediate: Reset main to the pre-release commit and re-merge the release branch only after cherry-picking the hotfix commit into develop. Long-term: Enforce a CI rule that rejects a hotfix finish unless the commit SHA appears in develop's history within 10 minutes. Add a pre-merge hook that checks for back-port.
Key lesson
  • Every hotfix must be merged into both main and develop.
  • Automate the back-port check — human memory fails under pressure.
  • Never assume a fix will survive the next release without explicit verification.
Production debug guideSymptom-to-action guide for the three most common GitFlow failures in production3 entries
Symptom · 01
Bug fixed in a hotfix reappears in the next release
Fix
Verify hotfix commit exists in develop: git log develop --oneline | grep <hotfix-sha>. If missing, cherry-pick the commit: git checkout develop && git cherry-pick <hotfix-sha>.
Symptom · 02
Merge conflict during git flow feature finish that blocks the whole team
Fix
Identify the conflict markers and resolve manually. After resolution, git add . && git commit (git flow finish will pause). Then run git flow feature finish again to complete the merge. To prevent conflicts, rebase feature branches onto develop daily: git flow feature rebase <name>.
Symptom · 03
Release branch is growing with unintended feature commits
Fix
Examine release branch log: git log develop..release/* --oneline. If you see feature additions, revert them with git revert <commit> and discuss process with the team. Block further feature commits via branch protection rules on the remote.
★ Quick GitFlow Debug Cheat SheetImmediate commands to diagnose common GitFlow problems during a production incident or release emergency.
Hotfix missing from develop
Immediate action
Check if back- port was done
Commands
git log develop --oneline | grep 8b4e9d0
git log main --oneline --all --graph | head -20
Fix now
git checkout develop && git cherry-pick 8b4e9d0
Merge conflict during feature finish+
Immediate action
Resolve conflict, then continue
Commands
git diff --check
git status
Fix now
Edit conflicting files, git add ., git commit, then git flow feature finish
Release branch has unintended commits+
Immediate action
Identify the rogue commits
Commands
git log --oneline main..release/1.1.0
git show <commit> --stat
Fix now
git revert <commit> && git push origin release/1.1.0
AspectGitFlowTrunk-Based Development
Permanent branchesmain + developmain only
Feature isolationDedicated feature/* branchesShort-lived branches or direct commits
Release managementExplicit release/* branch with stabilisation windowFeature flags control release visibility
Hotfix processhotfix/* from main, merged to main + developCommit directly to main, deploy immediately
Best suited forScheduled releases, versioned APIs, mobile appsSaaS with continuous deployment, high-frequency releases
Branch complexityHigh — 5 branch types with strict rulesLow — one branch, maximum simplicity
Parallel version supportExcellent — maintain v1.x and v2.x simultaneouslyDifficult — requires additional branching strategy
CI/CD compatibilityWorks well with staged environments (dev/staging/prod)Optimal — every commit can be production-ready
Team size sweet spotMedium to large teams with defined rolesAny size, especially high-trust senior teams
Risk of merge conflictsHigher — long-lived feature branches diverge moreLower — frequent integration keeps branches short

Key takeaways

1
GitFlow's two permanent branches (main and develop) serve completely different purposes
main is always production-ready and tagged; develop is the integration point for completed features and is allowed to be slightly unstable day-to-day.
2
Hotfix branches must always branch from main and merge into BOTH main AND develop
skipping the develop merge is the single most common GitFlow mistake and causes previously fixed bugs to reappear in future releases.
3
The --no-ff flag on every GitFlow merge is not optional
it preserves the visual history of branch boundaries in your Git graph, which is critical for debugging regressions and understanding what changed in each release.
4
Release branches provide a stabilisation window where no new features are allowed
this protects QA schedules and prevents scope creep.
5
GitFlow is the right choice for scheduled, versioned releases; it's the wrong choice for teams deploying multiple times per day
matching your branching strategy to your deployment cadence is more important than following any single model dogmatically.
6
Automate safety nets (CI checks for back-port, pre-merge hooks for --no-ff)
team memory alone is insufficient for consistent GitFlow enforcement.

Common mistakes to avoid

3 patterns
×

Forgetting to merge the hotfix branch back into develop

Symptom
The bug you just fixed in v1.0.1 silently reappears in v1.1.0. You'll only catch it during QA if you're lucky, or in production if you're not.
Fix
Make it a team rule and a CI check. After every git flow hotfix finish, immediately run git log develop --oneline | head -5 and verify the hotfix commit SHA appears in develop's history. Some teams add a post-merge hook that reminds developers of this step.
×

Committing new features onto a release branch

Symptom
Your release branch grows larger than planned, QA scope creeps, and the release gets delayed.
Fix
The release branch is a stabilisation zone, not a feature addition zone. Establish a written team policy: the only commits allowed on a release branch are bug fixes, documentation updates, and version bumps. If a developer wants to sneak in a small feature, it waits for the next cycle. Enforce this with pull request guidelines and code review.
×

Using fast-forward merges when finishing feature branches

Symptom
git log --graph on develop shows a straight line of commits with no visible trace of which feature each commit belonged to. Bisecting a regression becomes a nightmare because you can't isolate feature boundaries.
Fix
Always use --no-ff (no fast-forward) on all GitFlow merges. If you're using the git-flow CLI extension, this is handled automatically. If you're using raw Git, add a Git alias: git config --global alias.mff 'merge --no-ff' so the habit is built into your muscle memory.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Walk me through exactly what happens — step by step — when a critical bu...
Q02SENIOR
Why does GitFlow require merging a release branch into both main AND dev...
Q03SENIOR
A colleague suggests your team should switch from GitFlow to trunk-based...
Q01 of 03SENIOR

Walk me through exactly what happens — step by step — when a critical bug is discovered in production on a Friday afternoon and your team is using GitFlow. Which branches are involved, in what order, and why?

ANSWER
1. Identify the bug and confirm in main/production. 2. Create a hotfix branch from main: git checkout main && git checkout -b hotfix/<bug-description>. 3. Apply the fix, test locally, commit. 4. Merge hotfix into main with --no-ff, tag the release (e.g., v1.0.1). 5. Deploy main to production. 6. Immediately merge hotfix into develop: git checkout develop && git merge --no-ff hotfix/<branch>. 7. Delete the hotfix branch. The key step is step 6 — if skipped, the bug returns in the next release. Branch order: main → hotfix → main (for fix) → develop (for back-port). This order ensures the fix is in production and persists in future work.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between GitFlow and GitHub Flow?
02
Do I need to use the git-flow CLI extension to use GitFlow?
03
Can you have multiple feature branches active at the same time in GitFlow?
04
How do I handle a situation where a hotfix is needed while a release branch is active?
05
What is the recommended length for a feature branch in GitFlow?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Git. Mark it forged?

10 min read · try the examples if you haven't

Previous
Git Stash and Cherry-pick
5 / 19 · Git
Next
GitHub Pull Requests and Code Review