Pull Requests and Code Review
Proposing and reviewing changes on GitHub: the pull request workflow, good reviews, code owners, branch protection and rulesets, required checks, merge methods and the merge queue.
The pull request flow
The overview page introduces pull requests and code review; this page goes deeper. A pull request (PR) is not a Git feature but a feature of the hosting platform (GitLab calls the same thing a merge request). Git itself only knows branches and merges; GitHub adds a page per proposed merge where people discuss the change, review it line by line, and see the results of automated checks. Nothing reaches the target branch until someone presses the merge button (or a rule does it for them).
-
Branch. Start a topic branch from an up-to-date
main, for examplefeature/ntp(see Branches). -
Push. Commit your work and push the branch (
git push -u origin feature/ntp; see Remotes and collaboration). -
Open the pull request. On GitHub you choose the
base branch (where the change should go, usually
main) and the compare branch (your branch), then write a title and description. - Review and checks. Reviewers comment and approve; workflows (see GitHub Actions) build and test the branch and report a status.
- Merge. When the rules are satisfied, someone with write access merges, using one of the merge methods.
-
Delete the branch. Its commits now live in
main; the branch name is just clutter. The repository setting "Automatically delete head branches" does this for you.
The same flow from the terminal with the GitHub CLI (command-line
interface) (gh), which talks to GitHub over the network
and is therefore not run on this page:
git switch -c feature/ntp
# ... edit router.cfg, commit ...
git push -u origin feature/ntp
gh pr create --draft --title "Add NTP servers to R1" --body "Closes #42"
gh pr ready # mark the draft ready for review
gh pr checks # status of the automated checks
gh pr review --approve # run by the reviewer, not the author
gh pr merge --squash --delete-branch
Draft pull requests
A draft pull request is a pull request marked as work in progress: everyone can see it and the checks run, but it cannot be merged until the author marks it "Ready for review", and code owners are not asked to review it automatically while it is a draft. Open a draft early when you want feedback on a direction, or want the pipeline to run on your branch, before the work is finished. Converting back to draft is possible at any time.
Writing a good pull request
A reviewer has only what you give them: the diff and your words. Make it easy for them to say yes for the right reasons.
- Keep it small and about one thing. A change of a few dozen lines gets a careful review; a change of two thousand lines gets a skim and an approval. Split refactoring, formatting and behavior changes into separate pull requests.
-
A clear title. "Add NTP servers to R1 base
config" beats "updates". Many teams squash on merge, so the title
often becomes the commit message on
main. - A description that answers why. What problem does this solve, what did you change, what did you consider and reject, and how did you test it? Point reviewers to the risky part.
-
Link the issue. Writing
Closes #42(orFixes #42) in the description links the issue and closes it automatically when the pull request is merged into the default branch. -
Show, don't tell. For visible changes, add
screenshots; for configuration, paste the relevant
showoutput or a dry-run diff; for a bug, the before-and-after. - Review it yourself first. Read your own diff on the pull request page before asking anyone. You will find the debug print and the commented-out block.
A repository can offer a template for the description: a file
.github/pull_request_template.md whose content is
filled into every new pull request, for example with headings
"What", "Why" and "How tested".
Reviewing well
Code review is the practice of having someone other than the author read a change before it is merged, to catch defects, share knowledge and keep the code base consistent. Its value is as much in the second point as in the first: after a review, two people understand the change instead of one.
What to look for, roughly in order of importance:
- Does it do the right thing? Does it solve the linked issue, and does it handle the edge cases (an empty list, a missing file, a device that does not respond)?
- Is it safe? No secrets in the diff, input validated, permissions not widened without a reason. See Securing repositories.
- Is it tested? Are there tests for the new behavior, and did the checks pass for the right reason?
- Can the next person understand it? Names, structure, comments where the why is not obvious.
- Style comes last, and ideally is not a human job at all: a formatter and a linter in the pipeline settle it automatically.
Write comments that are kind and specific. Comment
on the code, not the person; say what you see and why it matters;
and make clear whether a point blocks the merge. Many teams prefix
optional remarks with "nit:" (a small, non-blocking point). Compare
"This is wrong" with "If the device is unreachable,
connect() raises and the loop stops for all remaining
devices. Should we catch the timeout and continue?" The second one
can be acted on.
Approve, comment or request changes
A review on GitHub is a set of line comments submitted together, with one of three outcomes:
| Outcome | Meaning | Effect on merging |
|---|---|---|
| Comment | Feedback or questions, no verdict | None |
| Approve | "I have reviewed this and it can be merged" | Counts toward the required number of approvals |
| Request changes | "This must change before it is merged" | Blocks merging while required reviews are enforced, until that reviewer approves or the review is dismissed |
Suggested changes
For small fixes, a reviewer can write a suggestion: a code
block marked suggestion inside a line comment,
containing the replacement lines. The author applies it with one
click ("Commit suggestion"), which creates a commit on the branch.
Several suggestions can be applied together as one commit.
The second server should be the backup at the other site. ```suggestion ntp server 198.51.100.11 ```
CODEOWNERS
CODEOWNERS is a file that maps
path patterns in a repository to the people or teams responsible for
them; GitHub automatically requests a review from the matching
owners when a pull request changes those paths. GitHub looks for the
file in .github/, then in the repository root, then in
docs/, and uses the first one it finds. The patterns
follow .gitignore syntax, owners need write access to
the repository, and the file must be smaller than 3 MB.
# Each line: a path pattern, then one or more owners. # When several patterns match a file, the LAST one wins. # Fallback owners for everything * @example-corp/netops # Router and switch configurations /configs/ @example-corp/netops @amara-okafor # Python tools, wherever they are *.py @joost-bakker # Workflows and this file itself /.github/ @example-corp/platform
Which owners are requested for a few changed files:
| Changed file | Last matching pattern | Review requested from |
|---|---|---|
README.md |
* |
@example-corp/netops |
configs/r1.cfg |
/configs/ |
@example-corp/netops and @amara-okafor |
configs/render.py |
*.py |
@joost-bakker only |
.github/workflows/ci.yml |
/.github/ |
@example-corp/platform |
On its own, CODEOWNERS only requests reviews. To make an owner's approval mandatory, enable "Require review from Code Owners" in a branch protection rule or ruleset (below); approval from any one of the listed owners is then enough. Protect the CODEOWNERS file itself with an owner, as the last line does, otherwise anyone can remove themselves from review.
Protecting branches
Without rules, anyone with write access can push straight to
main, merge without a review, or force push and rewrite
history. GitHub has two systems to prevent that.
Branch protection rules
A branch protection rule is the classic GitHub setting that enforces conditions on one branch name pattern, such as required reviews and passing checks before merging; it is configured under Settings → Branches. Only one branch protection rule applies to a given branch. Typical settings:
- Require a pull request before merging, with a number of approvals.
- Require review from Code Owners.
- Require status checks to pass before merging.
- Require signed commits (see Securing repositories).
- Require linear history (no merge commits on the branch).
- Block force pushes and branch deletion.
Rulesets
A repository ruleset is a named list of rules that applies to the branches or tags matching its target patterns; several rulesets can apply to the same branch, and they combine with each other and with branch protection rules, the most restrictive rule winning. Rulesets live under Settings → Rules → Rulesets and are the newer, more flexible system:
| Branch protection rule | Ruleset | |
|---|---|---|
| Rules per branch | One rule applies | Several rulesets layer; the most restrictive wins |
| Switching off | Delete or edit the rule | Set enforcement to Disabled and back to Active, without deleting it |
| Who may skip it | Administrators, depending on settings | An explicit bypass list of roles, teams or apps |
| Visibility | Repository administrators | Anyone with read access can see active rulesets |
| Scope | One repository | One repository, or many at once from the organization settings |
| Common rules | Required reviews, required status checks, signed commits, linear history and blocking force pushes exist in both | |
Availability depends on the plan (as of 2026): rulesets work on public repositories on GitHub Free, and on public and private repositories on Pro, Team and Enterprise Cloud. A repository can have up to 75 rulesets. Organizations on Team can also use push rulesets on private repositories, which block certain file paths, extensions or file sizes from being pushed at all. For new setups, prefer rulesets; existing branch protection rules keep working alongside them.
Required status checks
A
required status check
is an automated check, usually a CI (continuous integration) job,
that must report success on the latest commit of a pull request
before it can be merged. You pick the checks by name (for example
the job test from a workflow in
GitHub Actions). An extra option
requires the branch to be up to date with the base branch before
merging, so the checks ran against the code that will actually land.
Merge methods
The merge button has three variants; a repository can enable or
disable each one under Settings → General, in the "Pull Requests"
section. They produce different histories on main. To
see the difference, here is a pull request in the making:
feature/ntp has two commits, and meanwhile someone
added a README to main. (The examples on these pages
use a fixed author and date, so the hashes you see match the
output.)
git init -q netconf && cd netconf
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 base config"
git switch -q -c feature/ntp
printf 'ntp server 192.0.2.10\n' >> router.cfg && git commit -q -am "Add NTP server"
printf 'ntp server 192.0.2.11\n' >> router.cfg && git commit -q -am "Add second NTP server"
git switch -q main
printf '# Router configs\n' > README.md
git add README.md && git commit -q -m "Add README"
git log --graph --oneline --all
* 5c0926b Add second NTP server * f93e6eb Add NTP server | * 15186d5 Add README |/ * 3672ce5 Add R1 base config
GitHub performs the merge on its servers, but each method has a
local Git equivalent. The next example builds the same repository,
makes three clones (as if three people each pressed a different
button) and merges origin/feature/ntp in each:
# The same starting point as above
git init -q netconf && cd netconf
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "Add R1 base config"
git switch -q -c feature/ntp
printf 'ntp server 192.0.2.10\n' >> router.cfg && git commit -q -am "Add NTP server"
printf 'ntp server 192.0.2.11\n' >> router.cfg && git commit -q -am "Add second NTP server"
git switch -q main
printf '# Router configs\n' > README.md
git add README.md && git commit -q -m "Add README"
cd ..
# Three copies, one per merge method
git clone -q netconf merge-commit
git clone -q netconf squash
git clone -q netconf rebase
echo "== Create a merge commit"
cd merge-commit
git merge -q --no-ff -m "Merge pull request #12 from feature/ntp" origin/feature/ntp
git log --graph --oneline
cd ..
echo "== Squash and merge"
cd squash
git merge -q --squash origin/feature/ntp
git commit -q -m "Add NTP servers (#12)"
git log --graph --oneline
cd ..
echo "== Rebase and merge"
cd rebase
git switch -q -c ntp origin/feature/ntp
git rebase -q main
git switch -q main
git merge -q --ff-only ntp
git log --graph --oneline
== Create a merge commit * aaebbe2 Merge pull request #12 from feature/ntp |\ | * 5c0926b Add second NTP server | * f93e6eb Add NTP server * | 15186d5 Add README |/ * 3672ce5 Add R1 base config == Squash and merge Automatic merge went well; stopped before committing as requested Squash commit -- not updating HEAD * ba236aa Add NTP servers (#12) * 15186d5 Add README * 3672ce5 Add R1 base config == Rebase and merge * 3ad0f8d Add second NTP server * 6878372 Add NTP server * 15186d5 Add README * 3672ce5 Add R1 base config
-
Merge commit (
git merge --no-ff): the original commitsf93e6eband5c0926bare kept unchanged, and a new commit with two parents records the merge.--no-ffforces that merge commit even when a fast-forward would be possible; GitHub always creates one with this method. -
Squash and merge
(
git merge --squashplus a commit): a squash merge combines all changes of a branch into one new commit with a single parent on the target branch; the individual commits of the branch do not appear in its history.--squashonly stages the combined result, which is why Git says it stopped before committing and did not update HEAD: the commit is yours to make. -
Rebase and merge (
git rebase, then a fast-forward): the two commits are replayed on top ofmain. Same messages and changes, but new hashes (6878372,3ad0f8d), because each commit now has a different parent. See Rebasing and rewriting history.
| Method | History on main |
Good for | Watch out for |
|---|---|---|---|
| Create a merge commit (default) | All branch commits plus a merge commit; branch shape visible | Seeing exactly what was merged when, and from where |
A busy, non-linear graph; work-in-progress commits end up on
main
|
| Squash and merge | One new commit per pull request; linear | Clean history of one commit per change; easy to revert as a unit |
The branch commits are gone from main; keep
pull requests small so one commit is meaningful. Git no
longer sees the branch as merged, so delete it
|
| Rebase and merge | Every branch commit, replayed with new hashes; linear | Linear history that keeps well-crafted individual commits |
New hashes, so GitHub cannot show the original signatures as
verified; commits on main no longer match those
on the branch
|
A rule "Require linear history" allows only squash and rebase merges. Which method a team uses is part of its branching strategy; consistency matters more than the choice.
The merge queue
Required checks have a gap on busy repositories. Pull requests A and
B both pass their checks against the same main. A is
merged. B is still "green", but nobody has tested B
together with A, and the combination can break. Requiring
branches to be up to date closes the gap, but then every merge
forces everyone else to update and wait for a new run.
A merge queue is a GitHub feature that merges approved pull requests one after another and, before each merge, runs the required checks on the pull request combined with the latest base branch and every pull request queued ahead of it. Instead of pressing merge, you add the pull request to the queue; GitHub builds a temporary branch with the combination, runs the checks, and merges only if they pass. A failing pull request is removed from the queue and the ones behind it are tested again without it.
-
Workflows must also listen to the
merge_groupevent (next topull_request); otherwise the required checks never report for the queue and nothing merges. - Availability (as of 2026): public repositories owned by an organization, and private organization repositories on Enterprise Cloud.
- A merge queue cannot be combined with branch protection rules that use wildcard patterns.
on:
pull_request:
merge_group: # also run the checks for the merge queue
Review etiquette and common mistakes
- Huge pull requests. Review quality drops sharply with size. If a change cannot be small, split it into a series: preparation and refactoring first, behavior change after.
- Rubber-stamping. An approval with no comments within a minute of opening a 600-line diff is not a review; it is a signature on something unread. If you cannot review properly now, say so, or review part and say which part.
-
Letting pull requests rot. A pull request that
waits three days drifts away from
mainand collects conflicts. Many teams agree to respond within a working day. - Arguing about style in comments. Agree on a formatter once, run it in the pipeline, and spend review time on behavior.
- Force pushing during review. Rewriting the branch after reviews makes earlier comments point at commits that no longer exist. Push new commits during review; tidy up (or squash on merge) at the end.
- Approving your own work through a second account or merging with admin rights "just this once" defeats the purpose of the rules; use the bypass list deliberately and rarely.