Rebase vs Merge: A Practical Decision Framework
A practical decision framework for choosing between git rebase and merge, with real-world scenarios and team workflow patterns.
Rebase vs Merge: A Practical Decision Framework
The rebase-vs-merge debate has wasted more engineering hours than any actual merge conflict. The answer is not one or the other — it depends on context. This guide gives you a concrete decision framework based on branch ownership, team size, and history cleanliness requirements so you stop debating and start shipping.
Prerequisites
- Git 2.20+ installed
- Comfort with basic git operations (commit, branch, push, pull)
- Understanding of what a commit SHA is
- Access to a test repository to practice (do not learn rebase on your production repo)
How Merge Works
A merge combines two branches by creating a new commit that has two parents. Git supports two merge strategies.
Fast-Forward Merge
When the target branch has no new commits since the feature branch diverged, git just moves the pointer forward:
Before:
main: A --- B --- C
\
feature: D --- E
After (git merge feature):
main: A --- B --- C --- D --- E
No merge commit is created. The history is perfectly linear. This happens automatically when there is nothing to merge — git just fast-forwards.
$ git checkout main
$ git merge feature
Updating c3d2e1f..a9b8c7d
Fast-forward
src/auth.js | 45 +++++++++++++++++++++++++++++++++
1 file changed, 45 insertions(+)
Three-Way Merge
When both branches have new commits, git creates a merge commit with two parents:
Before:
main: A --- B --- C --- F
\
feature: D --- E
After (git merge feature):
main: A --- B --- C --- F --- M
\ /
feature: D --- E
M is the merge commit. It records that the two lines of development were combined.
$ git checkout main
$ git merge feature
Merge made by the 'ort' strategy.
src/auth.js | 45 +++++++++++++++++++++++++++++++++
src/config.js | 12 +++++++++
2 files changed, 57 insertions(+)
How Rebase Works
Rebase replays your commits on top of another branch. It rewrites commit history by creating new commits with new SHAs.
Before:
main: A --- B --- C --- F
\
feature: D --- E
After (git rebase main from feature):
main: A --- B --- C --- F
\
feature: D' --- E'
D' and E' have the same changes as D and E, but they are entirely new commits with new SHAs. The original D and E are orphaned.
$ git checkout feature
$ git rebase main
First, rewinding head to replay your work on top of it...
Applying: Add authentication module
Applying: Add session management
After rebasing, a merge from main becomes a fast-forward:
$ git checkout main
$ git merge feature
Updating f1e2d3c..e5d4c3b
Fast-forward
src/auth.js | 45 +++++++++++++++++++++++++++++++++
src/session.js | 28 +++++++++++++++++++++
2 files changed, 73 insertions(+)
Result: perfectly linear history with no merge commits.
The Decision Framework
Use this table. It covers 90% of real situations:
| Scenario | Use | Why |
|---|---|---|
| Local feature branch, not pushed | Rebase | Clean history, no risk to anyone |
| Feature branch, only you work on it | Rebase | Safe to rewrite, cleaner PR history |
| Shared branch, multiple contributors | Merge | Rebase rewrites SHAs, breaks collaborators |
| Integrating main into feature branch | Rebase | Keeps your commits on top, clean diff |
| Merging feature into main (final) | Merge (or squash-merge) | Records the integration point |
| Hotfix branch | Merge | Preserve the fix history for auditing |
| Long-running release branch | Merge | Too many commits to rebase safely |
The Golden Rule
Never rebase commits that have been pushed to a shared branch. Rebase rewrites commit SHAs. If someone else has based work on the original SHAs, their history diverges from yours and they get conflicts on every pull.
# THIS DESTROYS COLLABORATION:
$ git checkout shared-feature
$ git rebase main
$ git push --force # Everyone else's work is now broken
Interactive Rebase for History Cleanup
Before merging a feature branch, use interactive rebase to clean up your commit history:
$ git rebase -i main
This opens your editor with:
pick a1b2c3d Add user model
pick d4e5f6a Fix typo in user model
pick 7g8h9i0 Add user controller
pick j1k2l3m WIP: debugging auth
pick n4o5p6q Fix auth bug
pick r7s8t9u Add user routes
Clean it up:
pick a1b2c3d Add user model
fixup d4e5f6a Fix typo in user model
pick 7g8h9i0 Add user controller
squash j1k2l3m WIP: debugging auth
squash n4o5p6q Fix auth bug
pick r7s8t9u Add user routes
Commands:
- pick: keep the commit as-is
- squash: combine with previous commit, merge messages
- fixup: combine with previous commit, discard this message
- reword: keep changes, edit commit message
- drop: remove the commit entirely
Result: 6 messy commits become 3 clean ones. Your PR reviewer sees logical units of work, not your debugging journey.
Conflict Handling Differences
Merge Conflicts
With merge, you resolve conflicts once in the merge commit:
$ git merge feature
Auto-merging src/config.js
CONFLICT (content): Merge conflict in src/config.js
Automatic merge failed; fix conflicts and then commit the result.
$ vim src/config.js # resolve conflicts
$ git add src/config.js
$ git commit # creates merge commit
Rebase Conflicts
With rebase, you may resolve conflicts for each commit being replayed:
$ git rebase main
Applying: Add authentication module
Applying: Add session management
CONFLICT (content): Merge conflict in src/config.js
error: could not apply d4e5f6a... Add session management
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
$ vim src/config.js # resolve conflicts
$ git add src/config.js
$ git rebase --continue
Applying: Add session management
Applying: Add user routes
If your feature branch has 20 commits and the conflict exists in 5 of them, you resolve the same conflict 5 times. This is where people abandon rebase.
Tip: Use git rerere (reuse recorded resolution) to automate repeated conflict resolution:
$ git config --global rerere.enabled true
Git remembers how you resolved a conflict and automatically applies the same resolution next time.
Team Workflow Patterns
Trunk-Based Development
Everyone commits to main (or short-lived branches merged within a day).
# Start work
$ git checkout -b feature/add-auth main
# Stay current with main (rebase, since branch is short-lived and yours)
$ git fetch origin
$ git rebase origin/main
# Merge back (squash merge for single logical commit)
$ git checkout main
$ git merge --squash feature/add-auth
$ git commit -m "Add authentication module"
Best for: Small teams, high trust, continuous deployment.
GitHub Flow
Feature branches with pull requests, merged into main.
# Create feature branch
$ git checkout -b feature/user-dashboard main
# During development, rebase on main to stay current
$ git fetch origin
$ git rebase origin/main
# Push (force-with-lease since you rebased)
$ git push --force-with-lease origin feature/user-dashboard
# PR gets squash-merged or merge-committed via GitHub UI
Best for: Most teams. Clean PRs, simple branching.
GitFlow
Long-running develop and release branches.
# Feature branches merge into develop
$ git checkout develop
$ git merge --no-ff feature/user-dashboard
# Release branches merge into both main and develop
$ git checkout main
$ git merge --no-ff release/2.1.0
$ git checkout develop
$ git merge --no-ff release/2.1.0
Always use --no-ff (no fast-forward) in GitFlow. You want merge commits to mark integration points.
Best for: Teams with formal release cycles, multiple versions in production.
Configuring Git Pull Behavior
By default, git pull does a fetch + merge. You can change this:
# Make git pull rebase by default (recommended for feature branches)
$ git config --global pull.rebase true
# Or per-branch
$ git config branch.feature/auth.rebase true
# Use --ff-only to prevent accidental merge commits on main
$ git config --global pull.ff only
With pull.ff only, pulling on main fails if a fast-forward is not possible, forcing you to explicitly choose merge or rebase:
$ git pull
fatal: Not possible to fast-forward, aborting.
# Now explicitly choose:
$ git pull --rebase # or
$ git pull --no-rebase
Complete Working Example
Let us walk through a feature branch lifecycle using both approaches.
Setup
$ mkdir git-demo && cd git-demo && git init
$ echo "# App" > README.md && git add . && git commit -m "Initial commit"
$ echo "var config = { port: 3000 };" > config.js
$ git add . && git commit -m "Add config"
Approach A: Rebase Workflow
# Create feature branch
$ git checkout -b feature/auth
# Make commits on feature
$ echo "var auth = require('./auth');" >> app.js
$ git add . && git commit -m "Add auth import"
$ echo "function authenticate(req) { return true; }" > auth.js
$ git add . && git commit -m "Add auth module"
# Meanwhile, main has moved forward
$ git checkout main
$ echo "var logger = require('./logger');" >> app.js
$ git add . && git commit -m "Add logger"
# Back to feature, rebase onto main
$ git checkout feature/auth
$ git rebase main
First, rewinding head to replay your work on top of it...
Applying: Add auth import
Applying: Add auth module
# History is linear
$ git log --oneline --graph
* e5d4c3b (HEAD -> feature/auth) Add auth module
* a9b8c7d Add auth import
* f1e2d3c (main) Add logger
* c3d2e1f Add config
* 1a2b3c4 Initial commit
# Merge back to main (fast-forward)
$ git checkout main
$ git merge feature/auth
Fast-forward
$ git log --oneline --graph
* e5d4c3b (HEAD -> main, feature/auth) Add auth module
* a9b8c7d Add auth import
* f1e2d3c Add logger
* c3d2e1f Add config
* 1a2b3c4 Initial commit
Approach B: Merge Workflow
# Same setup, but merge instead
$ git checkout feature/auth
$ git merge main
Merge made by the 'ort' strategy.
$ git log --oneline --graph
* m3r4g5e (HEAD -> feature/auth) Merge branch 'main' into feature/auth
|\
| * f1e2d3c (main) Add logger
* | b2c3d4e Add auth module
* | a1b2c3d Add auth import
|/
* c3d2e1f Add config
* 1a2b3c4 Initial commit
# Merge back to main
$ git checkout main
$ git merge feature/auth
$ git log --oneline --graph
* x9y8z7w (HEAD -> main) Merge branch 'feature/auth'
|\
| * m3r4g5e Merge branch 'main' into feature/auth
| |\
| |/
|/|
* | f1e2d3c Add logger
| * b2c3d4e Add auth module
| * a1b2c3d Add auth import
|/
* c3d2e1f Add config
* 1a2b3c4 Initial commit
The merge approach preserves the full branch topology. The rebase approach gives you a clean line.
Common Issues and Troubleshooting
1. Rebase Causes Duplicate Commits After Push
$ git log --oneline
a1b2c3d Add feature # your commit
f4e5d6c Add feature # same change, different SHA!
Cause: You rebased after pushing, then pulled (which merged your old commits with the rebased ones).
Fix: After rebasing a pushed branch, always use --force-with-lease:
$ git push --force-with-lease origin feature/auth
Never use --force (it overwrites without checking). --force-with-lease fails if someone else pushed in the meantime.
2. Rebase Conflict Loop
$ git rebase main
CONFLICT in file.js
$ # resolve, add, continue
$ git rebase --continue
CONFLICT in file.js # same conflict again!
Cause: Multiple commits touch the same lines, causing repeated conflicts.
Fix: Enable rerere or squash your commits first:
$ git config rerere.enabled true
# Or squash before rebasing:
$ git rebase -i HEAD~5 # squash related commits
$ git rebase main # now fewer conflicts
Or just abort and merge instead: git rebase --abort && git merge main.
3. Lost Commits After Rebase
$ git rebase -i main
# Accidentally dropped a commit
$ git log --oneline
# commit is gone!
Fix: Use git reflog to find the lost commit:
$ git reflog
a1b2c3d HEAD@{0}: rebase (finish): returning to refs/heads/feature
f4e5d6c HEAD@{1}: rebase (pick): Add routes
c7d8e9f HEAD@{2}: rebase (start): checkout main
b2c3d4e HEAD@{3}: commit: Add auth module # <-- the lost one
$ git cherry-pick b2c3d4e
Reflog keeps everything for 90 days by default. Nothing is truly lost.
4. Merge Conflicts on git pull
$ git pull origin main
Auto-merging src/config.js
CONFLICT (content): Merge conflict in src/config.js
Automatic merge failed; fix conflicts and then commit the result.
Cause: git pull defaults to merge. If you have local commits and remote has diverged, you get merge commits in your feature branch.
Fix: Use git pull --rebase instead:
$ git pull --rebase origin main
# Or set it as default:
$ git config --global pull.rebase true
Best Practices
- Rebase local, merge shared. Rebase your own unpushed commits freely. Never rebase commits others have pulled.
- Use
--force-with-lease, never--force. It prevents overwriting a collaborator's work by checking remote state before pushing. - Clean up with interactive rebase before opening a PR. Squash WIP commits, fixup typos, reword vague messages. Reviewers should see logical commits, not your stream of consciousness.
- Enable
rerereglobally. It records conflict resolutions and replays them automatically. One-time setup saves hours:git config --global rerere.enabled true. - Set
pull.ff onlyon main/master. Prevents accidental merge commits when pulling the main branch. Forces explicit merge or rebase decisions. - Use squash-merge for feature PRs on small teams. Each feature becomes one commit on main. Simple history, easy reverts.
git merge --squash feature/auth. - Document your team's strategy. Put "we rebase feature branches and squash-merge to main" in your CONTRIBUTING.md. Consistency matters more than which strategy you pick.
- When in doubt, merge. Merge is always safe. Rebase requires understanding what you are doing. If a junior developer asks which to use, tell them merge.