Merge Strategies: Merge, Squash, Rebase

When you click the green button on a GitHub pull request, GitHub doesn’t just pick one way to combine your branch into main. It offers three distinct merge strategies, and each one produces a genuinely different commit history: a different set of commit objects, different parent links, and even different commit SHAs. Choosing the wrong one for your team isn’t fatal, but it does shape how easy your history is to read, bisect, and revert later. This lesson covers what each strategy actually does under the hood, how to trigger them from the GitHub web UI and the gh CLI, and the mistakes people make when switching between them.

Overview: How Each Merge Strategy Works

Recall the core of Git’s object model: every commit is an object that points to a tree (a snapshot of the project at that point) and to zero or more parent commits. A branch like main is nothing more than a movable pointer holding the SHA-1 (or SHA-256, on newer repos) of its latest commit. “Merging a pull request” always boils down to: create some new commit object(s), then move the main pointer to reference the newest one. The three strategies differ in how many commits they create and how many parents those commits get.

Merge commit

This is the default. GitHub performs the equivalent of git merge --no-ff: it creates one new commit whose tree is the merged result and which has two parents, the previous tip of main and the tip of the feature branch. Every original commit from the feature branch stays in the history, reachable through the second parent link. Nothing is rewritten; no existing commit gets a new SHA.

Squash and merge

GitHub computes the total diff between the merge base and the tip of your feature branch, then creates one brand-new commit with a single parent: the current tip of main. That new commit’s tree matches the final state of your branch, but it is a single commit object with a new SHA, not any of your original commits. The individual commits you made on the feature branch are not attached to main‘s history at all; they only continue to exist through the (now typically deleted) feature branch ref and, for a while, the reflog.

Rebase and merge

GitHub takes each commit on your PR branch, in order, and replays it as a new commit on top of the current tip of main, exactly what git rebase main would do locally. Because each replayed commit gets a new parent, each one also gets a new SHA even though the author, message, and diff content are preserved. No merge commit is created; once the last commit is replayed, main is simply fast-forwarded to point at it, producing a fully linear history.

Strategy New commits created History shape Original commit SHAs kept?
Merge commit 1 (the merge commit) Branching, with merge points Yes
Squash and merge 1 (a new squashed commit) Linear, one commit per PR No
Rebase and merge N (one per original commit) Linear, one commit per original commit No (new SHAs, same content)

Syntax

You can trigger any strategy from the pull request page’s merge button dropdown in the GitHub web UI, or from the terminal with the gh CLI:

gh pr merge "<pr-number>" --merge
gh pr merge "<pr-number>" --squash
gh pr merge "<pr-number>" --rebase
  • --merge — creates a merge commit (two parents).
  • --squash — creates one new commit representing the whole PR.
  • --rebase — replays the PR’s commits individually onto the base branch.
  • --delete-branch — deletes the head branch on GitHub after a successful merge.
  • --auto — queues the merge to happen automatically once required checks pass.
  • --subject "<text>" and --body "<text>" — override the commit message GitHub generates (most relevant for squash merges, where the subject becomes the new commit’s message).

A repository’s administrators can also restrict which of these three options are available at all, under Settings > General > Pull Requests, to enforce one convention team-wide.

Examples

Example 1: Merge commit

A long-running feature branch with several meaningful commits is merged, preserving all of them plus the branching structure.

gh pr merge 42 --merge --delete-branch

Output:

​✓ Merged pull request #42 (Add login page and auth redirect)
​✓ Deleted branch feature/login-page and switched to branch main

GitHub created a new merge commit on main with two parents, the old tip of main and the tip of feature/login-page, then deleted the now-merged remote branch and switched your local checkout back to main.

Example 2: Squash and merge

A short-lived bugfix branch has several noisy “wip” and “fix typo” commits that aren’t worth preserving individually.

gh pr merge 57 --squash --delete-branch

Output:

​✓ Squashed and merged pull request #57 (Fix null pointer in checkout flow)
​✓ Deleted branch bugfix/checkout-null-pointer and switched to branch main

All of that branch’s commits collapse into a single new commit on main, titled after the PR by default. The messy intermediate history never touches main at all.

Example 3: Rebase and merge

A PR with two clean, atomic, well-messaged commits (following Conventional Commits) is merged so each stays visible individually, but with no merge commit.

gh pr merge 63 --rebase --delete-branch

Output:

​✓ Rebased and merged pull request #63 (Add rate limiting middleware)
​✓ Deleted branch feature/rate-limiting and switched to branch main

Both commits are replayed onto main in order, each becoming a new commit object with a new SHA (since their parent changed), and main fast-forwards to the last one.

How It Works Step by Step

Comparing git log --oneline --graph after each strategy makes the difference concrete:

# After a "Merge commit" merge:
*   a1b2c3d (HEAD -> main) Merge pull request #42 from acme/feature/login-page
|\ 
| * 9f8e7d6 Add auth redirect after login
| * 5c4b3a2 Add login page markup and styles
|/  
* 1e2d3f4 Update README with setup instructions

# After a "Squash and merge" merge:
* d4e5f6a (HEAD -> main) Fix null pointer in checkout flow (#57)
* 1e2d3f4 Update README with setup instructions

# After a "Rebase and merge" merge:
* 7c6b5a4 (HEAD -> main) Add rate limiting middleware
* 3d2e1f0 Add tests for rate limiter
* 1e2d3f4 Update README with setup instructions

In every case, GitHub is doing server-side exactly what the equivalent local commands would do: git merge --no-ff for a merge commit, git merge --squash followed by a single git commit for squash, or git rebase main followed by a fast-forward for rebase and merge. The index and working tree on GitHub’s side are just used to compute the resulting tree object(s); nothing about your local clone changes until you git fetch or git pull.

Common Mistakes

Continuing work on a stale local branch after a squash or rebase merge

After a squash or rebase merge, your local feature branch’s commits no longer exist by SHA anywhere on main (only their content does). If you keep committing to your old local branch and push again, Git and GitHub will show a confusing diff that looks unrelated to what was already merged, because as far as SHAs go, it is.

git fetch origin
git switch main
git pull origin main
git branch -d feature/login-page

The fix is to delete the local branch once its PR is merged and start any new work from a freshly pulled main, rather than reusing the old branch.

Rebase-and-merging a branch other people have already based work on

The golden rule of rebasing applies here too: rebase and merge is safe for a PR branch that only you have been working on, because GitHub is rewriting commits that live solely on that PR branch. But if teammates have already branched off your PR branch and pulled it, a rebase merge replaces those commits’ SHAs on main, and your teammates’ branches will now appear to contain commits that no longer match anything on main, forcing painful manual reconciliation.

Merging with leftover conflict markers

When GitHub can’t auto-merge a PR, it asks you to resolve conflicts, either on the web or by pulling the branch locally. It’s easy to accept a resolution but forget to remove the markers Git inserts:

<<<<<<< HEAD
const MAX_RETRIES = 3;
=======
const MAX_RETRIES = 5;
>>>>>>> feature/rate-limiting

If this gets committed and merged as-is, the code simply won’t compile or run correctly. Always search the diff for <<<<<<< before finalizing a conflict resolution, and re-run your tests before merging.

Best Practices

  • Pick one default strategy per repository and restrict the others in Settings > General > Pull Requests so history stays consistent.
  • Prefer squash and merge for repos where contributors commit frequently with messy, in-progress messages; it keeps main‘s history one commit per feature or fix.
  • Prefer merge commit for long-lived feature branches where preserving exactly how work was structured, or being able to revert the whole feature with one git revert -m 1, matters.
  • Prefer rebase and merge only when contributors are disciplined about writing small, atomic, well-described commits following Conventional Commits, since every one of them survives individually on main.
  • Write good PR titles and descriptions; with squash merges, the PR title typically becomes the new commit’s subject line by default.
  • Always use --delete-branch (or check the equivalent web UI box) to keep the branch list clean once a PR is merged.
  • After any squash or rebase merge, delete and stop using your local copy of the merged branch rather than building further commits on top of it.
  • When updating your own PR branch before a rebase merge, prefer git push --force-with-lease over a bare --force so you don’t silently overwrite commits you haven’t fetched.

Practice Exercises

  1. Create a branch feature/pricing-page with three commits, open a PR, and merge it using the merge commit strategy. Run git log --oneline --graph on main afterward and identify the merge commit’s two parents.
  2. Create a branch bugfix/typo-fixes with four small, messy commits (like “fix typo”, “fix typo again”), open a PR, and merge it using squash and merge. Confirm that main gained exactly one new commit, not four.
  3. Create a branch feature/dark-mode with two clean, well-described commits, merge it using rebase and merge, then try comparing the SHAs of your original local commits to the ones now on main. They should differ even though the content is identical — explain why, in terms of what a commit’s SHA is derived from.

Summary

  • A merge commit strategy creates one new commit with two parents and keeps every original commit reachable in history.
  • Squash and merge creates a single new commit representing the whole PR; the original per-commit history never lands on the base branch.
  • Rebase and merge replays each PR commit individually onto the base branch as new commits with new SHAs, producing a linear history with no merge commit.
  • All three are performed server-side by GitHub and only appear locally once you fetch or pull.
  • Never rebase-merge a branch that other collaborators have already built work on top of.
  • Delete and stop reusing a local branch once its PR has been squash- or rebase-merged, to avoid confusing “phantom” diffs.