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-leaseover a bare--forceso you don’t silently overwrite commits you haven’t fetched.
Practice Exercises
- Create a branch
feature/pricing-pagewith three commits, open a PR, and merge it using the merge commit strategy. Rungit log --oneline --graphonmainafterward and identify the merge commit’s two parents. - Create a branch
bugfix/typo-fixeswith four small, messy commits (like “fix typo”, “fix typo again”), open a PR, and merge it using squash and merge. Confirm thatmaingained exactly one new commit, not four. - Create a branch
feature/dark-modewith 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 onmain. 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.
