Skip to content

GitHub Pull Requests Explained: A Complete Engineering Guide

Lesson 1 of 12Beginner → Intermediate12 min readGitHub Engineering · Pull RequestsVerified: gh 2.98.0 and the GitHub REST API, August 2026

A pull request stores two branch references and a conversation. It does not store your changes.

Once that is genuinely internalised, most pull request behaviour stops being surprising and becomes predictable — because you can reason about it from the data model rather than remembering rules.

A pull request records, at minimum:

FieldMeaning
BaseThe branch you want the changes to end up in — usually main
HeadThe branch containing the changes
NumberShared with Issues in one per-repository sequence
Stateopen, closed, or merged
DraftWhether it is marked as not ready for review

Everything else — the diff, the file list, the commit list, the conflict status — is computed from those two branch references at the moment you look.

That is the whole trick. Five consequences follow immediately, and each one is something people otherwise learn by surprise:

  1. Pushing to the head branch updates the pull request. Nothing needs re-opening. The pull request points at a branch; the branch moved; the diff is recalculated.
  2. Changing the base branch changes the diff. If main advances, the comparison changes even though your branch did not.
  3. Closing a pull request deletes nothing. The commits are on a branch. You closed a conversation.
  4. Deleting the head branch leaves the pull request readable. GitHub retains the objects so the record stays meaningful.
  5. A pull request between forks works identically. The two branches simply live in different repositories — see Forks.

You do not have to take this on trust. The API returns exactly the fields described:

Terminal window
gh pr view PULL_NUMBER --json baseRefName,headRefName,state,isDraft,mergeable

What it doesFetches a pull request and prints its base branch, head branch, state and draft flag.

Why we run itSeeing the stored fields directly is more convincing than a description of them — note that the diff is not among them, because it is derived.

Expected resultA small JSON object containing the branch references and state.

Output:

{
"baseRefName": "main",
"headRefName": "add-retry-handling",
"isDraft": false,
"mergeable": "MERGEABLE",
"state": "OPEN"
}

Two branch names and some derived state. No file contents, no patch — those are computed when requested.

What happens between opening and merging

A vertical sequence: a pull request is opened, review is requested, checks run, feedback arrives, the branch is updated, checks re-run, approval is given, policy is satisfied, and the pull request merges.

OpenedBase and head recordedReview requestedHumans, or CODEOWNERS routingChecks runCI reports statusFeedbackComments and requested changesBranch updatedNew push — diff and checks recomputeApprovedOne input among severalPolicy satisfiedEverything required is now trueMergedA commit is written to base

The loop between feedback and branch update usually runs more than once, and that is normal — a pull request that merges without iteration is either trivial or under-reviewed.

You need a branch with at least one commit that differs from the base. GitHub offers to open a pull request as soon as you push a branch, which is convenient and occasionally premature.

Terminal window
gh pr create --base main --head add-retry-handling \
--title "Add retry handling to the HTTP client" \
--body "Closes #42"

The --fill flag populates the title and body from your commits, which is a good reason to write decent commit messages.

A draft pull request is one marked explicitly as not ready. Checks still run; review can still be left; but it cannot be merged, and CODEOWNERS review requests are not sent automatically.

Draft state exists to make “I want you to see this, but not to sign it off” expressible. Without it, people signal the same thing by prefixing titles with WIP:, which no tooling understands.

Terminal window
gh pr create --draft --title "Spike: alternative retry strategy" --body "Not for merge — seeking direction"
gh pr ready PULL_NUMBER

Covered properly in Draft Pull Requests.

Reviewers leave comments, and submit a review that is one of three verdicts: approve, request changes, or comment without a verdict.

The distinction matters because only two of them affect mergeability, and only when policy says they do. A plain comment carries no state.

Pull Request Reviews covers the mechanics.

External systems — CI, linters, security scanners — report status against the head commit. GitHub displays them; it does not run them, unless they are GitHub Actions workflows.

A check is attached to a commit, not to a pull request. Push a new commit and checks re-run, because the thing they described no longer exists at the tip.

The moment a Git object is finally written.

This is the most commonly misunderstood aspect of pull requests, and it is worth stating explicitly.

Approval is one input. A pull request is mergeable when every condition the repository imposes is satisfied. Depending on configuration, that can include:

  • No merge conflicts with the base branch
  • The required number of approving reviews
  • Approval from code owners for the paths touched
  • All required status checks passing
  • All conversations resolved
  • The branch up to date with base
  • No ruleset violations — signed commits, linear history, commit metadata rules
  • Successful validation in a merge queue

A pull request can carry five approvals and be unmergeable because one check is failing or one conversation is unresolved. Equally, a repository with no policy at all allows merging with zero approvals.

Terminal window
gh pr view PULL_NUMBER --json mergeable,mergeStateStatus,reviewDecision,statusCheckRollup \
--jq '{mergeable, state: .mergeStateStatus, review: .reviewDecision}'

Output:

{
"mergeable": "MERGEABLE",
"review": "APPROVED",
"state": "BLOCKED"
}

That combination — no conflicts, approved, still blocked — is exactly the case that confuses people. mergeable reports whether Git can combine the branches. mergeStateStatus reports whether policy permits it. They answer different questions and both must be satisfied.

GitHub’s merge button offers up to three options, and they produce three different histories. This is a Git decision surfaced as a GitHub setting.

StrategyWhat lands on baseHistory
Merge commitAll your commits, plus a merge commitFull, with an explicit join
Squash and mergeOne new commit containing everythingLinear, one commit per pull request
Rebase and mergeYour commits, replayed onto baseLinear, no merge commit

None is correct in general; each is correct for some repositories. The trade-offs are covered in Squash Merging, Rebase and Merge and Merge Commits.

While a pull request is open, the base branch moves. Two consequences: the diff may conflict, and your checks were run against a base that no longer exists.

Terminal window
gh pr update-branch PULL_NUMBER

This merges the latest base into your head branch — or rebases, depending on repository configuration. Either way, checks re-run against the new tip.

Requiring branches to be up to date before merging closes the correctness gap and creates a race: on a busy repository, by the time your checks finish, main has moved again. That race is precisely what merge queues exist to solve.

Closing marks the pull request as closed rather than merged. Nothing is deleted; the branch, the commits and the conversation all remain, and it can be reopened while the branch exists.

Closing a stale pull request is a kindness, not a rejection — an open pull request implies someone intends to finish it, and a queue full of abandoned ones makes the real work invisible.

Because the pull request stores references rather than content, pushing has effects that are worth enumerating — several of them surprise people.

When you push to the head branch:

  • The diff recomputes. Against the merge base, which may itself have changed.
  • Checks re-run. They were attached to the previous head commit, which is no longer the head.
  • Approvals may be dismissed, if the repository dismisses stale reviews.
  • Review comments may become outdated. A comment on a line that no longer exists is marked outdated and collapsed.
  • The pull request stays open. It was never tied to a particular commit.

That last point is what makes the model coherent. A pull request is a proposal about a branch, and a branch is a moving reference. Everything else follows.

Force-pushing does all of the above and additionally detaches review context more aggressively, because the commits reviewers commented on no longer exist by those IDs. GitHub retains enough to render the old comparison, but the connection between a comment and its line is weakened.

When the head branch lives in a fork, everything works identically with two differences worth knowing.

The head is qualified. owner:branch rather than branch, because the branch name alone is ambiguous across repositories.

CI runs with reduced permissions. Workflows triggered by a pull request from a fork run without access to repository secrets and with a read-only token. This is a deliberate security boundary: the alternative is that anyone opening a pull request could run arbitrary code with your repository’s credentials.

That restriction explains a common confusion — a contributor’s pull request showing a check failure that has nothing to do with their change, because the workflow needs a secret it cannot have. The fix is a workflow designed for the fork case, not a permissions change.

Maintainers can usually push to the contributor’s branch, if allow edits from maintainers is left enabled. That is a grant of write access to that branch of the fork, and it is what makes “let me just fix that for you” possible.

GitHub exposes pull request refs through Git itself, which is occasionally very useful:

Terminal window
git fetch origin 'refs/pull/*/head:refs/remotes/pr/*'
git branch -r | grep '^ pr/'
git switch --detach pr/128

That fetches every pull request’s head as a local ref. It is how gh pr checkout works for forks without adding a remote, and it is a good demonstration that the commits are ordinary Git objects available through ordinary Git — while the pull request itself, the reviews and the conversation are not there at all.

Terminal window
git log --oneline main..pr/128
git diff main...pr/128

The three-dot form is the comparison GitHub shows. The two-dot form lists commits your branch adds. Both are useful and they answer different questions — which is worth internalising, because “the diff looks wrong” is usually a two-dot-versus-three-dot confusion.

Issues and pull requests share one sequence per repository. There is no Issue #12 and pull request #12; whichever was created first has that number.

This is a hint about the underlying model: GitHub stores a pull request as an Issue with additional fields. The consequences surface at the API boundary, where the REST Issues endpoint returns pull requests too — covered in Issue Automation — and in issue_comment webhook events firing for comments on both.

For everyday use it means #128 unambiguously identifies one thing, and linking works the same way regardless of which it is.

Thinking a pull request stores the changes. It stores two branch references.

Assuming approval means mergeable. Approval is one condition among several.

Opening a pull request from main. Your main should track the upstream; branch for work.

Force-pushing during review without saying so. It invalidates the comparison reviewers were reading. Sometimes necessary — announce it.

Letting a pull request sit for weeks. The base moves, conflicts accumulate, and context is lost.

Confusing the three merge strategies. They produce genuinely different histories.

Treating checks as attached to the pull request. They are attached to a commit.

  1. In your practice repository, create a branch, commit, and open a pull request with gh pr create.
  2. Inspect it with gh pr view --json baseRefName,headRefName,state and confirm only references are stored.
  3. Push another commit and re-run the command — note the pull request updated with no action from you.
  4. Commit directly to main, then look at the pull request’s diff and confirm it did not absorb that change. That is the merge-base comparison.
  5. Close the pull request, confirm with git log that your commits still exist, then reopen it.
  6. Merge it, then run git log --oneline --graph on main and identify what the merge wrote.

Steps 4 and 6 are the ones worth dwelling on: the first demonstrates the three-dot comparison, the second shows the only point at which Git state actually changed.

Four states, and the transitions between them are worth having straight because two are one-way.

┌─────────┐
│ Draft │
└────┬────┘
│ mark ready (reversible)
┌────▼────┐
┌────│ Open │────┐
│ └─────────┘ │
│ close │ merge
│ (reversible) │ (one-way)
┌──▼─────┐ ┌────▼────┐
│ Closed │ │ Merged │
└────────┘ └─────────┘

Draft ↔ open is reversible in both directions.

Open ↔ closed is reversible while the head branch still exists. Delete the branch and reopening becomes impossible.

Open → merged is one-way. A merged pull request cannot be un-merged; undoing the change means a revert, which is a new commit and, if you want it reviewed, a new pull request.

The asymmetry matters for automation: closing is a safe operation to script, merging is not.

What the pull request page is actually showing

Section titled “What the pull request page is actually showing”

Each tab answers a different question, computed at the moment you look:

Conversation — the record: description, comments, review verdicts, and events such as pushes and label changes. This is the only part that is stored rather than derived.

Commits — the commits on the head branch that are not on the base, computed live.

Checks — statuses reported against the head commit. Attached to the commit, not the pull request.

Files changed — the three-dot diff against the merge base, computed live.

Understanding which of those are derived explains why a pull request updates itself when the base branch moves, and why “the diff changed and I did not push anything” is normal rather than alarming.

  • A pull request stores a base reference, a head reference and a conversation — the diff is derived.
  • Pushing updates it automatically; closing destroys nothing; deleting the branch leaves it readable.
  • The diff is a merge-base comparison, which is why unrelated base commits do not appear.
  • Mergeability combines Git mergeability with repository policy, and approval is only one input.
  • Checks attach to commits, so a new push invalidates them.
  • Merge, squash and rebase produce three different histories, and two of them rewrite.

Four questions that test whether the model has landed. Each answer follows directly from “a pull request stores two branch references”.

Why does closing a pull request not delete the commits? They are on a branch; the pull request only referenced it. Closing changed a record, not the repository.

Why does the diff change when you have not pushed? It is computed against the merge base, and the base branch moved. Nothing about your branch changed; the comparison did.

Why do checks disappear after a push? They were attached to a commit that is no longer the head. They are not attached to the pull request at all.

Why can an approved pull request be unmergeable? Approval is one review record among several policy requirements, and Git mergeability is a separate question again.

All four are frequently asked, and all four are the same fact applied differently. That is the value of holding a model rather than a list of behaviours.

Everything that has happened to a pull request is available as an event stream, which is the most complete record GitHub offers and is not visible as a list anywhere in the interface.

Terminal window
gh api "repos/OWNER/REPO/issues/128/timeline?per_page=100" --paginate \
-H "Accept: application/vnd.github+json" \
--jq '.[] | [.created_at[0:19], .event, (.actor.login // "-")] | @tsv'
2026-08-20T09:14:02 committed -
2026-08-20T09:15:41 labeled alice
2026-08-20T09:16:03 review_requested alice
2026-08-20T11:02:18 reviewed bob
2026-08-20T13:40:55 head_ref_force_pushed carol
2026-08-21T08:12:30 merged alice

Note the endpoint path: issues/128/timeline, not pulls/128/timeline — another consequence of a pull request being modelled as an Issue with extra fields.

Two things this answers that nothing else does. Who force-pushed, and when — the head_ref_force_pushed event is the record of history being rewritten mid-review, which otherwise leaves no trace anyone can find. And the true sequence, when a pull request’s conversation has been edited and the comment timestamps no longer tell the story.

For incident review — “how did this change reach production without the check passing?” — the timeline is usually where the answer is.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

A pull request shows a diff. What does GitHub actually store for it?
Show answer

A base reference, a head reference and the conversation — the diff is derived — A pull request is two branch references plus discussion. The diff is computed on demand from the merge base, which is why pushing to the head branch updates it automatically.

A pull request is approved by a required reviewer. Can it be merged?
Show answer

Not necessarily — approval is one condition; checks, conflicts and policy also apply — Mergeability combines Git mergeability with repository policy. Required status checks, up-to-date rules, conflicts and rulesets all apply independently of approval.

Checks passed. The author pushes one more commit. What happens to the checks?
Show answer

They are invalidated — checks attach to commits, and the new commit has none — Status checks are recorded against a specific commit. A new push means a new head commit with no results, so required checks must run again before merge.

Professional ToolkitCODEOWNERS, pull request and issue templates, and repository configuration checklists ready to adapt.