paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

Git Merge vs. Rebase: Best Practices and Pitfalls for Professional Teams

Understanding what each operation actually does to your history, so you can choose deliberately instead of by habit

Introduction

Few Git questions generate as much confident, conflicting advice as "should I merge or rebase?" Some teams enforce a strict linear-history policy and treat merge commits as noise to be eliminated; others treat rebasing shared history as close to a cardinal sin. Both positions are defensible, and both come from real experience - which is exactly why the debate persists instead of resolving into a single obvious answer.

This article isn't going to declare a universal winner, because there isn't one; merge and rebase solve different problems and produce genuinely different histories, and the right choice depends on what a team actually needs from its version history - a faithful record of what happened, or a clean, readable narrative of how the codebase evolved. What this article will do is explain precisely what each operation does at the level of Git's underlying commit graph, so that "merge or rebase" stops being a matter of habit or tribal preference and becomes a deliberate decision based on what each operation actually costs and provides.

Getting this right matters beyond aesthetics. A team that misunderstands what rebase does to commit hashes will eventually rewrite shared history in a way that breaks every other collaborator's local branches. A team that never rebases can end up with a commit graph so tangled with merge commits that git bisect and git log become nearly useless for understanding when a bug was actually introduced. Both outcomes are avoidable with a precise enough mental model, which is what the rest of this article builds.

The Problem: Divergent History and What to Do About It

Every time two branches diverge - one person commits to main while another commits to a feature branch based on an earlier point in main - Git eventually needs a way to reconcile them when the feature branch is ready to be incorporated back. This reconciliation is not optional; it's a structural necessity of any version control system that allows concurrent, independent work, and Git offers exactly two fundamentally different strategies for it: creating a new commit that records both histories as parents (merging), or rewriting one branch's commits so they appear to have been created after the other branch's latest commit, as if the divergence never happened (rebasing).

The two strategies preserve different information. A merge commit is an honest record: it says, explicitly and permanently, "these two lines of development happened concurrently and were combined at this point." A rebase erases that concurrency from the historical record - after rebasing a feature branch onto main, the resulting commits look exactly as if they'd been written sequentially after every commit already on main, with no indication that the author was actually working from an earlier snapshot while other changes landed in parallel. Neither of these is objectively more "correct" - they're different answers to the question of what a commit graph is actually for: a factual record of events, or a curated narrative optimized for readability.

This is the crux of nearly every merge-versus-rebase disagreement in practice: teams that prioritize historical accuracy and traceability of exactly what happened tend to favor merge commits, while teams that prioritize a clean, linear, easy-to-follow history tend to favor rebasing before integration. Both are legitimate priorities, and understanding which one your team actually values - rather than which one a previous engineer happened to prefer - is the real starting point for choosing a policy.

Deep Technical Explanation: What Merge and Rebase Actually Do

Merge

git merge combines two branches by creating a new commit with two parent commits - the tip of the branch you're merging into, and the tip of the branch you're merging in. Git first identifies the common ancestor of the two branches (the point where they diverged), then computes the combined changes from both branches since that ancestor, and if those changes don't conflict, produces a merge commit automatically. If a merge can be resolved without creating a new commit at all - because the target branch hasn't moved since the feature branch diverged - Git performs what's called a fast-forward merge, simply moving the target branch's pointer forward to the feature branch's tip, with no merge commit created; this can be disabled with git merge --no-ff when a team wants an explicit merge commit recorded even in this case, for consistency in how integration points are represented in history.

The defining property of merge is that it never rewrites existing commits. Every commit that existed on either branch before the merge keeps its original hash, its original parent, and its original position in history - the merge commit simply adds a new node that references both prior tips. This is what makes merge safe to use on shared, already-pushed branches: because nothing already public is altered, other collaborators who've based work on either branch are entirely unaffected by a merge happening elsewhere.

Rebase

git rebase works completely differently: it takes the commits unique to your current branch (the ones that exist on your branch but not on the branch you're rebasing onto), and replays each one, in order, as a new commit on top of the target branch's current tip. "Replays" is precise here - Git computes the diff each original commit introduced, and reapplies that diff as a new commit with a new parent, which means every rebased commit gets an entirely new hash, even if its actual content is identical to the original. This is the single most important technical fact for understanding rebase's risks: a rebased commit is not the same commit, cryptographically, even though it may look identical in a diff.

# Before rebase: feature branch diverged from main three commits ago
#   main:    A---B---C---D
#                \
#   feature:      E---F---G

git checkout feature
git rebase main

# After rebase: E, F, and G are replayed on top of D, producing new
# commits E', F', G' with entirely new hashes - even though their
# content matches the originals.
#   main:    A---B---C---D
#                         \
#   feature:               E'---F'---G'

Because every rebased commit is a genuinely new object with a new hash, anyone who had already pulled the original E, F, G commits now has a local history that's diverged from the rewritten one - their Git client has no way to know that E' is "the same as" E in any way it can automatically reconcile, and pulling the rebased branch will appear as an entirely separate, conflicting set of commits requiring a forced update (git pull --rebase or a manual reset) to resolve. This is the precise technical reason behind the widely repeated rule that you should never rebase a branch that others have already pulled from - it's not a stylistic guideline, it's a direct consequence of how rebase constructs new commit objects.

Practical Implementation: Common Workflows in Detail

Interactive Rebase for Cleaning Up Local History

One of rebase's most genuinely valuable uses has nothing to do with integrating with another branch - it's cleaning up your own, not-yet-shared commit history before it becomes public. Interactive rebase (git rebase -i) lets you reorder, squash, edit, or drop commits on your own branch before opening a pull request, turning a messy sequence of "WIP," "fix typo," "actually fix it this time" commits into a small number of coherent, well-described changes.

# Squashing the last four commits on a feature branch into one,
# before opening a pull request, so reviewers see a coherent change
# rather than the actual messy sequence of incremental fixes.
git rebase -i HEAD~4

# In the interactive editor that opens:
# pick a1b2c3d Add user authentication endpoint
# squash e4f5g6h fix typo
# squash h7i8j9k address review comment
# squash k0l1m2n actually fix the validation logic

# Result: a single commit combining all four, with one clean message.

This use case is uncontroversial even among teams that otherwise avoid rebasing shared branches, precisely because it operates entirely on commits that haven't been pushed or shared yet - there's no one else whose history could possibly diverge as a result, since no one else has a copy of these commits to begin with. This distinction - rebasing your own unpublished work versus rebasing a branch others have already pulled - is the single most important practical rule in this entire topic, and it resolves the large majority of real-world merge-versus-rebase disagreements once teams agree to draw the line there.

Rebasing a Feature Branch Before Merging

A common team workflow uses rebase to keep a feature branch current with main during development, and then merges (often with a fast-forward, since the rebase already made the histories align) once the feature is ready.

# Keeping a feature branch up to date with main via rebase,
# rather than merging main into the feature branch repeatedly.
git checkout feature
git fetch origin
git rebase origin/main

# Resolve any conflicts introduced by commits that landed on main
# since the feature branch was created, one commit at a time -
# rebase surfaces conflicts per replayed commit, not all at once.
git add .
git rebase --continue

# Once complete, the feature branch's commits sit cleanly on top
# of the latest main, and merging it later can fast-forward cleanly.
git checkout main
git merge feature

This pattern's practical benefit is that conflict resolution happens incrementally, once per replayed commit, against the specific change that commit introduced - which is often easier to reason about than a single, large merge conflict representing the combined divergence of an entire branch's worth of changes at once. The trade-off, covered in more depth in the pitfalls section below, is that this workflow is only safe if the feature branch in question isn't already shared with other collaborators who have their own local copies of its pre-rebase commits.

Enforcing History Policy With Git Hooks

Teams that want to enforce a specific merge-versus-rebase policy - for instance, requiring linear history on main - often do so with server-side hooks or CI checks rather than relying purely on convention, since convention alone is easy to forget under deadline pressure.

# A CI check (conceptually run as part of a pull request pipeline)
# that rejects a PR if it would introduce a merge commit into main,
# enforcing a linear-history policy programmatically rather than by convention.
import subprocess
import sys

def has_merge_commits(base_branch: str, head_branch: str) -> bool:
    result = subprocess.run(
        ["git", "log", "--merges", "--oneline", f"{base_branch}..{head_branch}"],
        capture_output=True,
        text=True,
        check=True,
    )
    return bool(result.stdout.strip())

def main() -> int:
    if has_merge_commits("origin/main", "HEAD"):
        print(
            "This branch contains merge commits from main. "
            "Rebase onto main instead of merging it in before opening this PR.",
            file=sys.stderr,
        )
        return 1
    print("No merge commits detected; history is linear.")
    return 0

if __name__ == "__main__":
    sys.exit(main())

Codifying a policy this way removes the ambiguity of relying on every contributor remembering and correctly applying a convention, and it surfaces the problem at review time rather than after a tangled history has already been merged into a shared branch.

Trade-offs and Pitfalls

Several mistakes recur often enough across teams that they're worth calling out explicitly, each tracing back to a specific misunderstanding of what merge or rebase actually does.

Rebasing a branch other people have already pulled. This is the single most damaging and most common rebase mistake, and it follows directly from the technical explanation above: rebasing creates new commits with new hashes, so anyone who already has the old commits locally will experience the rewritten branch as a divergent, conflicting history the next time they try to sync. The practical fallout ranges from a confusing forced-push reconciliation to, in the worst case, someone accidentally reintroducing the pre-rebase commits by merging their stale local branch back in, undoing the cleanup the rebase was meant to achieve. The reliable rule: only rebase commits that exist solely on a branch you personally control and haven't shared, or that your team has explicitly agreed to treat as rewritable (some workflows do permit force-pushing a shared feature branch, but this requires explicit team-wide agreement and communication every time it happens, not silent assumption).

Losing the context of why a set of changes were combined. Aggressive rebasing and squashing, applied without judgment, can compress a feature's development into commits that no longer reflect any of the actual reasoning or incremental decisions that went into it - which matters when a future engineer, or a future version of the same engineer, is trying to understand not just what changed but why. Merge commits, by contrast, naturally preserve a feature branch's incremental commit history nested inside the merge, at the cost of a less linear top-level view. Neither extreme (rebase-and-squash everything into oblivion, or merge everything and never clean up local history at all) tends to serve long-term maintainability as well as a deliberate middle ground: clean up genuinely uninteresting incremental commits (typos, WIP saves) via interactive rebase before sharing, but preserve commits that represent genuinely distinct, meaningful steps.

Resolving the same conflict repeatedly during a long rebase. Because rebase replays commits one at a time, a long-lived feature branch with many commits, rebased onto a main that's moved significantly, can require resolving essentially the same conflict multiple times - once for each commit that touches the conflicting area. Git's rerere (reuse recorded resolution) feature exists specifically to mitigate this by remembering how a conflict was resolved once and automatically reapplying that resolution if the same conflict recurs, but many engineers aren't aware it exists, and manually resolving the same merge conflict five times in a row during a single rebase is a common, avoidable frustration.

Misunderstanding fast-forward merges as "no history." Some engineers assume a fast-forward merge means the feature branch's commits vanish or get combined; in fact, a fast-forward merge preserves every individual commit exactly as it was, it simply doesn't add an additional merge commit on top, because none was structurally necessary. Confusion here sometimes leads teams to force --no-ff everywhere reflexively, or conversely to fear that fast-forwarding loses information it doesn't actually lose - both stemming from an incomplete picture of what a fast-forward merge actually does versus what a squash merge (a genuinely different operation, which does combine commits into one) does.

Force-pushing without communicating. Rebasing a branch that's already been pushed requires a force push (git push --force, or the safer git push --force-with-lease, which fails if the remote branch has commits your local copy doesn't know about, protecting against accidentally clobbering someone else's work you weren't aware of) to update the remote. A force push performed without any communication to collaborators who might be working from that branch is a recurring source of lost work and confusion, and it's avoidable simply by treating a force push to any branch with more than one contributor as an action that requires a heads-up first, every time.

Best Practices for Merge and Rebase Policy

A handful of clear conventions, agreed on at the team level rather than left to individual preference, resolve most of the friction this topic tends to generate.

Draw a firm line between unpublished and published history, and make it the actual policy rather than an unstated assumption: rebase and clean up freely on commits that exist only on your own machine and haven't been pushed or shared, and treat any commit that's been pushed to a branch other people might have pulled as effectively immutable, to be integrated via merge (or a deliberate, communicated rebase with explicit team buy-in) rather than silently rewritten.

Use --force-with-lease instead of a bare --force for any force push, as a default habit rather than an occasional precaution - it costs nothing when a force push is genuinely safe, and it prevents the specific, damaging scenario where a force push overwrites commits you didn't know existed on the remote because someone else pushed to the same branch after your last fetch.

Decide, as an explicit team policy communicated in a contributing guide or equivalent, whether main's history should be linear (achieved by requiring feature branches to be rebased before merging, or by using squash merges for pull requests) or whether merge commits recording integration points are considered valuable. Either choice is legitimate; what causes friction is a team where some contributors assume one policy and others assume the other, discovering the mismatch only when a tangled or unexpectedly rewritten history actually causes a problem.

Adopt interactive rebase as a normal, encouraged step before opening a pull request, specifically for cleaning up a branch's own unpublished commit sequence into something coherent - this is close to a universally beneficial practice regardless of a team's broader merge-versus-rebase policy for integration, since it operates purely on history no one else has a stake in yet.

Learn and use git rerere for any workflow involving long-lived branches that get rebased repeatedly against a fast-moving main, since it directly addresses the specific, common frustration of resolving the same conflict multiple times across a single rebase operation.

Key Takeaways

Analogies and Mental Models

A useful way to hold the distinction in mind: merging is like combining two separate written accounts of a trip into a single document that explicitly notes "at this point, these two travelers' paths came together" - nothing about either traveler's original account is altered, and a reader can see exactly when and how the two threads joined. Rebasing is like asking one traveler to rewrite their journal as if they'd taken the trip after the other traveler, in a single continuous sequence, with no indication two separate journeys ever happened concurrently - the resulting account reads more smoothly, but it's now a fictionalized reordering rather than a literal record of what actually happened when.

This is also why rebasing shared history is risky in a way merging never is: if someone else already has a copy of the original journal, and you hand them a rewritten version claiming to be the same trip, their copy and yours no longer agree on what happened - not because either is wrong, exactly, but because rewriting a shared, already-distributed record necessarily creates two incompatible versions of events that need to be reconciled by hand.

The 80/20 Insight

Almost all of the real-world pain associated with the merge-versus-rebase debate comes down to a single distinction that's easy to state and easy to violate under pressure: rebase only what hasn't been shared, merge (or carefully coordinate a rebase) for anything that has. Teams that internalize just this one rule, and don't otherwise worry too much about linear-versus-merge-commit aesthetics, avoid nearly every serious incident this topic tends to produce. The remaining considerations - squash merges, rerere, enforced linear-history policies - meaningfully improve history readability and reduce friction, but they're refinements on top of that one foundational safety rule, not substitutes for understanding it.

Conclusion

Merge and rebase aren't competing solutions to the same problem; they're different, deliberate trade-offs between preserving an honest record of concurrent development and constructing a clean, readable narrative after the fact. Neither is a universally correct default, and a team that picks one dogmatically without understanding what it's actually trading away is making a weaker decision than a team that understands both operations precisely and chooses deliberately, context by context.

The technical mechanics underneath both operations are genuinely simple once stated precisely - merge adds a new commit with two parents and never rewrites anything; rebase replays commits as new objects with new hashes and parents - and that simplicity is exactly what makes the practical rule so reliable: rebase is safe wherever nothing else depends on the commits being rewritten, and risky the moment something does. Everything else in this topic, including the entire merge-versus-rebase cultural debate, is downstream of that one fact.

References

  1. Git Documentation. "git-merge." git-scm.com/docs/git-merge
  2. Git Documentation. "git-rebase." git-scm.com/docs/git-rebase
  3. Pro Git Book (Chacon, S., & Straub, B.). "Git Branching - Rebasing." git-scm.com/book/en/v2/Git-Branching-Rebasing
  4. Pro Git Book (Chacon, S., & Straub, B.). "Git Tools - Rerere." git-scm.com/book/en/v2/Git-Tools-Rerere
  5. Git Documentation. "git-push" (documentation for --force-with-lease). git-scm.com/docs/git-push
  6. Atlassian Git Tutorials. "Merging vs. Rebasing." atlassian.com/git/tutorials/merging-vs-rebasing
  7. Git Documentation. "git-rebase - Interactive Mode." git-scm.com/docs/git-rebase#_interactive_mode