Rebase vs Merge: A Practical Decision Framework
A comprehensive comparison of Git rebase and merge strategies with practical guidelines, real-world scenarios, team workflows, and a decision framework for choosing the right approach.
Rebase vs Merge: A Practical Decision Framework
The rebase-vs-merge debate generates more heat than light. Both operations integrate changes from one branch into another. They produce the same code. The difference is how they record history. Merge preserves the branching structure. Rebase creates a linear sequence. Neither is universally better — the right choice depends on your workflow, team size, and how you use history.
I use both daily and have strong opinions about when each is appropriate. This guide gives you the decision framework I use, not a religious argument for one side.
Prerequisites
- Git installed (v2.20+)
- Understanding of Git commits, branches, and basic merging
- A repository with feature branches
- Terminal access
What Merge Does
Merge creates a new commit that combines two branches:
Before merge:
main: A ─── B ─── C
\
feature: D ─── E
After git merge feature:
main: A ─── B ─── C ─────── M
\ /
feature: D ─── E
The merge commit M has two parents: C (from main) and E (from feature). The branching history is preserved.
git checkout main
git merge feature
# Creates merge commit M
Fast-Forward Merge
When main has not moved since the feature branched off:
Before:
main: A ─── B
\
feature: C ─── D
After git merge feature:
main: A ─── B ─── C ─── D
No merge commit is created. Main's pointer simply moves forward. This only happens when there is no divergence.
# Force a merge commit even when fast-forward is possible
git merge --no-ff feature
What Rebase Does
Rebase replays your commits on top of the target branch:
Before rebase:
main: A ─── B ─── C
\
feature: D ─── E
After git rebase main (on feature):
main: A ─── B ─── C
\
feature: D' ─── E'
Commits D' and E' are new commits with different hashes but the same changes. The branch point moved from B to C.
Then merge feature into main (fast-forward):
main: A ─── B ─── C ─── D' ─── E'
The result is a linear history with no merge commits.
git checkout feature
git rebase main
git checkout main
git merge feature # Fast-forward
Interactive Rebase
Clean up commits before integrating:
git rebase -i main
pick abc1234 WIP: start search feature
pick def5678 fix typo in search
pick ghi9012 WIP: more search work
pick jkl3456 Add search tests
pick mno7890 fix test assertion
Change to:
pick abc1234 feat: add search feature
fixup def5678 fix typo in search
fixup ghi9012 WIP: more search work
pick jkl3456 test: add search tests
fixup mno7890 fix test assertion
Result: Two clean commits instead of five messy ones.
The Decision Framework
Use Merge When:
1. Working on shared branches with a team
Multiple developers push to the same branch. Rebasing rewrites history, which conflicts with commits other developers have already based their work on.
# Safe — merge preserves everyone's commits
git checkout develop
git merge feature/auth
2. You want to preserve the branching context
The merge commit documents that these changes came from a feature branch. This is useful for understanding why a group of commits exists.
main: ... ── M ── ... ── M ── ...
/ \ / \
feature-1: a b c d :feature-2
3. The branch has been pushed and others may have pulled it
Rebase changes commit hashes. Anyone who pulled the original commits now has conflicts.
4. You are merging long-lived branches (release, develop)
Long-lived branches diverge significantly. Merge commits mark the integration points clearly.
5. You want a simple, safe operation
Merge never rewrites history. It never loses commits. It always creates a clear record of what happened.
Use Rebase When:
1. Cleaning up local commits before sharing
Your feature branch has WIP commits, typo fixes, and debug additions. Rebase cleans this up before it goes into the shared history.
# Before pushing, clean up your commits
git rebase -i main
2. Keeping a feature branch up to date with main
Instead of merge commits cluttering your branch:
# Instead of: git merge main (adds merge commits)
git rebase main
3. You are the only person working on the branch
No one else has pulled your commits, so rewriting history is safe.
4. You want a linear history in main
Some teams prefer git log to read like a sequential story:
abc1234 feat: add user search
def5678 fix: handle empty search results
ghi9012 feat: add search pagination
jkl3456 refactor: extract search service
vs. merge-based history:
abc1234 Merge branch 'feature/search'
def5678 Merge branch 'fix/search-empty'
ghi9012 Merge branch 'feature/search-pagination'
5. Contributing to open source
Most open-source maintainers prefer rebased, clean commits that tell a clear story.
The Golden Rule
Never rebase commits that have been pushed to a shared branch.
Rebase rewrites history. If someone has based work on your original commits, their work becomes incompatible with your rewritten history. They will get merge conflicts that are confusing and error-prone.
# SAFE — rebasing local, unpushed commits
git rebase -i main
# SAFE — rebasing a branch only you use (even if pushed)
git rebase main
git push --force-with-lease # Force push YOUR branch
# DANGEROUS — rebasing a shared branch
git checkout develop
git rebase main # DON'T DO THIS
Workflow Patterns
Pattern 1: Rebase and Merge (Recommended for Most Teams)
# Develop on a feature branch
git checkout -b feature/user-search
# Make commits
git commit -m "WIP: search endpoint"
git commit -m "fix: typo"
git commit -m "add search tests"
git commit -m "fix test"
# Before merging, rebase onto latest main
git fetch origin
git rebase origin/main
# Clean up commits
git rebase -i origin/main
# Squash WIP and fix commits into clean commits
# Push and create PR
git push -u origin feature/user-search
# Merge via PR (with merge commit or squash)
This gives you the best of both worlds: clean commits from rebase, clear integration points from merge.
Pattern 2: Squash Merge
GitHub and GitLab offer "Squash and Merge" for pull requests. This squashes all feature branch commits into a single commit on main:
Before (feature branch has 5 commits):
main: A ─── B ─── C
\
feature: D ─── E ─── F ─── G ─── H
After squash merge:
main: A ─── B ─── C ─── S
(S contains all changes from D through H)
Pros:
- Main has one commit per feature — very clean
- No need to clean up feature branch commits
Cons:
- Loses granular commit history
- Hard to bisect within a feature's changes
- Attribution goes to the merger, not individual committers
Pattern 3: Merge Only (Simple Teams)
# Never rebase, always merge
git checkout main
git merge feature/search --no-ff
# History shows all branches and merge points
This is the simplest workflow. It works well for small teams who prefer safety over clean history. The --no-ff flag ensures a merge commit even for fast-forward merges, creating a clear record.
Pattern 4: Rebase Only (Solo Developers)
# Keep everything linear
git checkout feature
git rebase main
git checkout main
git merge feature # Always fast-forward
# History is a single line
git log --oneline
# abc1234 feat: add search
# def5678 fix: pagination
# ghi9012 refactor: extract service
Perfect for solo developers who want the cleanest possible history.
Handling Conflicts
Merge Conflicts
Merge conflicts happen once:
git merge feature
# CONFLICT in src/api.js
# Fix conflicts
git add src/api.js
git commit # Creates merge commit with resolved conflicts
Rebase Conflicts
Rebase conflicts can happen at every commit being replayed:
git rebase main
# CONFLICT applying commit D
# Fix conflicts
git add src/api.js
git rebase --continue
# CONFLICT applying commit E (possibly same file)
# Fix again
git add src/api.js
git rebase --continue
If a rebase has many conflicts, it may be easier to abort and merge instead:
git rebase --abort
git merge main
Reducing Rebase Conflicts
# Rebase frequently — small conflicts are easy
git fetch origin
git rebase origin/main
# Do this daily, not weekly
# Use rerere to remember resolutions
git config rerere.enabled true
Configuring Default Behavior
Pull Strategy
# Default: pull with merge
git config pull.rebase false
# Pull with rebase (recommended for feature branches)
git config pull.rebase true
# Pull with rebase, but preserve merge commits
git config pull.rebase merges
# Require explicit choice
git config pull.ff only
Branch-Specific Pull Strategy
# Rebase when pulling a specific branch
git config branch.feature.rebase true
# Merge when pulling main
git config branch.main.rebase false
Merge Strategy for Main
# Always create merge commits (no fast-forward)
git config merge.ff false
# Or per-branch
git config branch.main.mergeoptions "--no-ff"
Complete Working Example: Team Workflow
#!/bin/bash
# team-workflow.sh — demonstrates the recommended workflow
# Starting a feature
git checkout main
git pull origin main
git checkout -b feature/payment-refund
# Development (messy commits are fine)
echo "Working..."
git commit -am "WIP: refund endpoint"
git commit -am "add refund service"
git commit -am "fix import"
git commit -am "add refund tests"
git commit -am "fix test data"
git commit -am "handle edge case"
# Before creating PR: update and clean up
git fetch origin main
git rebase origin/main
# Clean up commits with interactive rebase
git rebase -i origin/main
# Result: 2-3 clean commits instead of 6 messy ones
# Push to remote
git push -u origin feature/payment-refund
# Create PR (merge into main via PR review)
# After approval, merge via GitHub/GitLab UI
Git Aliases for the Workflow
git config --global alias.sync '!git fetch origin && git rebase origin/main'
git config --global alias.cleanup '!git rebase -i origin/main'
git config --global alias.pub '!git push -u origin $(git rev-parse --abbrev-ref HEAD)'
git config --global alias.done '!git checkout main && git pull && git branch -d @{-1}'
Usage:
git sync # Rebase onto latest main
git cleanup # Interactive rebase to clean commits
git pub # Push current branch
git done # Switch to main, pull, delete previous branch
Side-by-Side Comparison
| Aspect | Merge | Rebase |
|---|---|---|
| History shape | Non-linear (branches visible) | Linear (single line) |
| Commit hashes | Preserved | Changed (new hashes) |
| Merge commits | Created | None |
| Conflict resolution | Once per merge | Once per replayed commit |
| Safety | Cannot lose work | Can lose work if misused |
| Shared branch safe | Yes | No (rewrites history) |
git log readability |
Noisy with merge commits | Clean sequential list |
git bisect |
Works with merge commits | Works, possibly cleaner |
| Reversibility | git revert -m 1 <merge> |
Requires git reflog |
| Learning curve | Simple | More complex |
Common Issues and Troubleshooting
"Cannot rebase: You have unstaged changes"
You have uncommitted changes when trying to rebase:
Fix: Stash your changes first: git stash && git rebase main && git stash pop. Or commit your WIP: git commit -am "WIP" && git rebase main.
Rebase created duplicate commits
You rebased commits that were already pushed, then pulled the old commits from the remote:
Fix: This is why you should never rebase pushed commits on shared branches. If it happened, use git reset --hard origin/branch-name to match the remote. For your own branches, use git push --force-with-lease after rebasing.
Merge commit makes git bisect harder
Bisect follows both parents of merge commits, making the path confusing:
Fix: Use git bisect start --first-parent to only follow the main branch's merge commits. Or use rebase to maintain linear history, which makes bisect straightforward.
Interactive rebase lost a commit
You accidentally dropped a commit during interactive rebase:
Fix: Use git reflog to find the commit hash before the rebase started: git reflog | head -20. Reset to that point: git reset --hard HEAD@{N}. The reflog keeps everything for 30 days.
Best Practices
- Rebase local, merge shared. Rebase your own unpushed commits freely. Merge when integrating into shared branches. This gives you clean history without the risks of history rewriting.
- Use
--force-with-leaseinstead of--force. When force-pushing after a rebase,--force-with-leasechecks that nobody pushed new commits to the remote branch. Regular--forceoverwrites blindly. - Configure
pull.rebase truefor feature branches. This avoids merge commits when pulling updates to your feature branch. Set it globally or per-branch. - Squash WIP commits during rebase. Clean up "fix typo," "WIP," and "debug" commits before they reach the main branch. The feature branch is your draft — main is the published version.
- Use merge commits on main for traceability.
--no-ffmerge commits create a clear record of what features were integrated and when. This aids debugging and auditing. - Rebase frequently to minimize conflicts. Rebasing daily onto main creates small, easy conflicts. Rebasing after two weeks creates massive ones.
- Agree on a team strategy and enforce it. Mixed strategies in the same repo create confusion. Document the workflow and configure branch protection rules to enforce it.