Blog

Pull request metrics: what to measure in code review

A working set of review metrics you can compute from GitHub's API, with the research behind each one.

Alex Mercer

The pull request metrics worth tracking are time to first review, review turnaround, PR size, review depth and rework after review, with change failure rate as the outcome they should improve. Measure them per team, as medians and 75th percentiles over several weeks, mostly from data GitHub already records, and keep them out of performance reviews.

These code review metrics follow a pull request through the code review process: it waits for a reviewer, goes back and forth, merges, and then holds up in production or doesn't. Speed metrics on their own reward rubber-stamp approvals, so pair every speed number with a depth or outcome number.

Pull request metrics at a glance

Metric

What it shows

Data source

Pair it with

Time to first review

How long authors wait for a person to look

created_at or the ready_for_review event, then the first human review's submitted_at

Review depth

Review turnaround

How fast each round of feedback comes back

review_requested and reviewed timeline events

Rework after review

PR size

How much a reviewer has to hold in their head

additions, deletions and changed_files

Review depth

Review depth

Whether a person engaged with the change

Reviews, review comments, approvals with no comments

Time to first review

Rework after review

What changed because of the review

Commits after the first human review, review rounds

Change failure rate

Change failure rate

Whether reviewed changes hold up in production

Deploy and incident records, or merged reverts as a proxy

Review turnaround

Time to first review

Time to first review runs from the moment a pull request is ready to the first review by someone other than the author. If the PR started as a draft, start the clock at its ready_for_review timeline event. Otherwise use created_at. The clock stops at the earliest submitted_at from the reviews endpoint, once you've dropped reviews by the author and by bot accounts, whose user.type is Bot.

Google's guide sets the ceiling: "One business day is the maximum time it should take to respond to a code review request" (Google on review speed). Google's own data sits far below that. In a case study of about 9 million changes at Google, authors waited a median of under an hour for first feedback on small changes and about 5 hours on very large ones.

Report the 75th percentile next to the median, because a healthy median can hide a slow quarter of PRs. When Meta looked at its data in early 2021, the median diff spent only a few hours in review, but at the 75th percentile review time rose by as much as a day. It also found that "the longer someone's slowest 25 percent of diffs take to review, the less satisfied they were by their code review process," and made the 75th percentile of time in review its north star metric (Meta Engineering).

Wall-clock hours also include nights and weekends. Count business hours, or use the same clock everywhere so periods stay comparable.

Review turnaround and time to merge

Review turnaround is the time from an author asking for another look to the reviewer's next review. In the timeline, pair each review_requested event with the next reviewed event from the same reviewer. Google's guide ranks this above the total: "it's even more important for the individual responses to come quickly than it is for the whole process to happen rapidly." Our code review best practices cover the habits behind quick responses, such as waiting for a break point instead of interrupting focused work.

Time to merge runs from ready to merged_at. It mixes the wait for the first review, the rounds after it, and the time from approval to merge, which covers CI and merge queues. Split it before you act on it, because each wait has a different fix.

The slow tail responds to reminders aimed at whoever is blocking. In a randomized trial on 147 repositories at Microsoft, a service called Nudge reminded the author or reviewers of overdue pull requests and cut resolution time by 60% for 8,500 pull requests, compared with overdue ones that got no reminder.

PR size

Size is additions plus deletions, with changed_files alongside. The single pull request endpoint returns these fields, and the list endpoint doesn't. Lockfiles and generated code inflate the count. Filter them out with the files endpoint, which returns up to 3,000 files per pull request.

Big changes get weaker reviews. A Microsoft study of about 1.5 million review comments from five projects found that "the more files that are in a change, the lower the proportion of comments in the code review that will be of value to the author of the change" (Bosu, Greiler and Bird, MSR 2015). Google's guide lets reviewers reject a change for its size alone (Google on small changes), and DORA's guide calls reducing the batch size of changes a common way to improve its delivery metrics.

Watch the share of PRs over a size you pick, as well as the median, because PRs are getting bigger. cubic's State of AI coding 2026 report found that the median PR in its dataset grew from 56 lines changed in June 2025 to 124 in May 2026, a 2.2x increase.

Review depth

Depth asks whether a person engaged with the change. Track the share of merged PRs with at least one review from someone other than the author, review comments per PR, and the share of approvals that came with no comments, split by PR size. The pull request object carries comments and review_comments counts, and the review comments endpoint lists the inline comments themselves.

Shallow review shows up later as defects. In a study of Qt and ITK releases, components with more self-approved changes, changes approved without discussion, or changes approved faster than 200 lines an hour tended to have more post-release defects. The models estimated up to five additional post-release defects from low review participation (McIntosh et al., MSR 2014).

Comment counts aren't bug counts. When Bacchelli and Bird sorted 570 review comments at Microsoft, only 14% were about defects, and code improvements made up the largest group at 29% (Bacchelli and Bird, ICSE 2013). A better signal is whether a comment led to a change. In the Microsoft usefulness study, useful comments "very often trigger changes in close proximity" to the lines they were left on, and the authors used that signal to classify comments at scale.

Rework after review

Rework is what changed because of the review. Count the commits made after the first human review, using each commit's committer date from the PR's commits endpoint, and count review rounds, the times a reviewer asked for changes before approving. A rebase rewrites committer dates, so treat any PR with a head_ref_force_pushed event in its timeline as unreliable for this count.

Some rework is the point of review. In Google's data, over 80% of changes needed at most one iteration of resolving comments. Zero rework on a large PR that was approved quickly deserves a look, since a rubber-stamp approval leaves exactly that pattern. Keep this separate from DORA's deployment rework rate, which counts unplanned deployments after production incidents.

Change failure rate, the outcome

Review metrics describe the process, and change failure rate tells you whether it works. DORA calls it change fail rate and defines it as "the ratio of deployments that require immediate intervention following a deployment," which usually means a rollback or a hotfix (DORA's metrics guide). It needs deploy and incident data, which pull requests don't hold. From GitHub alone, merged reverts and hotfix PRs make a rough proxy. GitHub's Revert button opens a new pull request that reverts the original merge commit, so reverts show up in your PR data.

DORA's guide suggests tracking measures that "can serve as leading indicators for the software delivery metrics," and its first example is how long code reviews take. Read the two together: if time to first review drops in the same quarter that change failure rate climbs, look at review depth before you celebrate.

The same guide says DORA's metrics are "best suited for measuring one application or service at a time." Review metrics work best at the same grain: one team or one service, compared with its own past.

Getting the numbers out of GitHub

The REST API holds the data for every metric here except change failure rate. This Python script (3.8 or later) reads the 50 newest closed pull requests in a repository with the GitHub CLI's gh api command, which uses the sign-in from gh auth login. It prints how many merged PRs had no human review, then the median and 75th percentile for time to first human review, time to merge, size and commits after review.

import json, statistics, subprocess
from datetime import datetime

REPO = "your-org/your-repo"

def get(path):
    out = subprocess.run(["gh", "api", f"repos/{REPO}/{path}"],
                         capture_output=True, text=True, check=True).stdout
    return json.loads(out)

def ts(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))

def hours(start, end):
    return (end - start).total_seconds() / 3600

rows = []
for pr in get("pulls?state=closed&per_page=50"):
    if not pr["merged_at"]:
        continue
    n, author = pr["number"], pr["user"]["login"]
    events = get(f"issues/{n}/timeline?per_page=100")
    ready = [e["created_at"] for e in events if e.get("event") == "ready_for_review"]
    start = ts(ready[0] if ready else pr["created_at"])
    human = [r for r in get(f"pulls/{n}/reviews?per_page=100")
             if r.get("submitted_at") and r["user"]["type"] != "Bot"
             and r["user"]["login"] != author and ts(r["submitted_at"]) >= start]
    first = ts(human[0]["submitted_at"]) if human else None
    commits = get(f"pulls/{n}/commits?per_page=100")
    detail = get(f"pulls/{n}")
    rows.append({
        "hours_to_first_review": hours(start, first) if first else None,
        "hours_to_merge": hours(start, ts(pr["merged_at"])),
        "lines_changed": detail["additions"] + detail["deletions"],
        "human_reviews": len(human),
        "commits_after_review": sum(
            1 for c in commits
            if ts(c["commit"]["committer"]["date"]) > first) if first else None,
    })

print("merged PRs:", len(rows))
print("merged with no human review:", sum(r["human_reviews"] == 0 for r in rows))
for key in ("hours_to_first_review", "hours_to_merge",
            "lines_changed", "commits_after_review"):
    values = [r[key] for r in rows if r[key] is not None]
    if len(values) >= 2:
        q = statistics.quantiles(values, n=4)
        print(f"{key}: median {q[1]:.1f}, p75 {q[2]:.1f}")
import json, statistics, subprocess
from datetime import datetime

REPO = "your-org/your-repo"

def get(path):
    out = subprocess.run(["gh", "api", f"repos/{REPO}/{path}"],
                         capture_output=True, text=True, check=True).stdout
    return json.loads(out)

def ts(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))

def hours(start, end):
    return (end - start).total_seconds() / 3600

rows = []
for pr in get("pulls?state=closed&per_page=50"):
    if not pr["merged_at"]:
        continue
    n, author = pr["number"], pr["user"]["login"]
    events = get(f"issues/{n}/timeline?per_page=100")
    ready = [e["created_at"] for e in events if e.get("event") == "ready_for_review"]
    start = ts(ready[0] if ready else pr["created_at"])
    human = [r for r in get(f"pulls/{n}/reviews?per_page=100")
             if r.get("submitted_at") and r["user"]["type"] != "Bot"
             and r["user"]["login"] != author and ts(r["submitted_at"]) >= start]
    first = ts(human[0]["submitted_at"]) if human else None
    commits = get(f"pulls/{n}/commits?per_page=100")
    detail = get(f"pulls/{n}")
    rows.append({
        "hours_to_first_review": hours(start, first) if first else None,
        "hours_to_merge": hours(start, ts(pr["merged_at"])),
        "lines_changed": detail["additions"] + detail["deletions"],
        "human_reviews": len(human),
        "commits_after_review": sum(
            1 for c in commits
            if ts(c["commit"]["committer"]["date"]) > first) if first else None,
    })

print("merged PRs:", len(rows))
print("merged with no human review:", sum(r["human_reviews"] == 0 for r in rows))
for key in ("hours_to_first_review", "hours_to_merge",
            "lines_changed", "commits_after_review"):
    values = [r[key] for r in rows if r[key] is not None]
    if len(values) >= 2:
        q = statistics.quantiles(values, n=4)
        print(f"{key}: median {q[1]:.1f}, p75 {q[2]:.1f}")
import json, statistics, subprocess
from datetime import datetime

REPO = "your-org/your-repo"

def get(path):
    out = subprocess.run(["gh", "api", f"repos/{REPO}/{path}"],
                         capture_output=True, text=True, check=True).stdout
    return json.loads(out)

def ts(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))

def hours(start, end):
    return (end - start).total_seconds() / 3600

rows = []
for pr in get("pulls?state=closed&per_page=50"):
    if not pr["merged_at"]:
        continue
    n, author = pr["number"], pr["user"]["login"]
    events = get(f"issues/{n}/timeline?per_page=100")
    ready = [e["created_at"] for e in events if e.get("event") == "ready_for_review"]
    start = ts(ready[0] if ready else pr["created_at"])
    human = [r for r in get(f"pulls/{n}/reviews?per_page=100")
             if r.get("submitted_at") and r["user"]["type"] != "Bot"
             and r["user"]["login"] != author and ts(r["submitted_at"]) >= start]
    first = ts(human[0]["submitted_at"]) if human else None
    commits = get(f"pulls/{n}/commits?per_page=100")
    detail = get(f"pulls/{n}")
    rows.append({
        "hours_to_first_review": hours(start, first) if first else None,
        "hours_to_merge": hours(start, ts(pr["merged_at"])),
        "lines_changed": detail["additions"] + detail["deletions"],
        "human_reviews": len(human),
        "commits_after_review": sum(
            1 for c in commits
            if ts(c["commit"]["committer"]["date"]) > first) if first else None,
    })

print("merged PRs:", len(rows))
print("merged with no human review:", sum(r["human_reviews"] == 0 for r in rows))
for key in ("hours_to_first_review", "hours_to_merge",
            "lines_changed", "commits_after_review"):
    values = [r[key] for r in rows if r[key] is not None]
    if len(values) >= 2:
        q = statistics.quantiles(values, n=4)
        print(f"{key}: median {q[1]:.1f}, p75 {q[2]:.1f}")

It skips reviews by bots and by the PR's author, and starts the clock when a draft was marked ready for review. Each endpoint returns at most 100 items per page, so long-running PRs need paging.

If you'd rather not write code, GitHub's open-source issue-metrics action, now at github-community-projects/issue-metrics, reports time to first response and time to first review for pull requests. It leaves out time spent in draft and ignores comments from the author and from bots. For spot checks, the search qualifier is:pr is:merged review:none lists merged pull requests that haven't been reviewed.

Traps that turn code review metrics into targets

Goodhart's law

DORA lists "setting metrics as a goal" first among its common pitfalls and names Goodhart's law, usually stated as: when a measure becomes a target, it ceases to be a good measure. A target for time to first review can produce quick comments that say nothing. A target for PR count can produce PRs split where the code doesn't split.

The next pitfall on the list is relying on one metric. Teams should pick several, "including some with a healthy amount of tension between them." Google's chapter on measuring engineering productivity makes the same point with what it calls an extreme example: "I can make your review velocity very fast: just remove code reviews entirely" (Software Engineering at Google). When Meta worked on review speed, it tracked "Eyeball Time," the total time reviewers spent looking at a diff, as a guardrail against rubber-stamp reviewing.

Ranking people

The same Google chapter warns against judging engineers by these numbers: "If productivity metrics are used for performance reviews, engineers will be quick to game the metrics, and they will no longer be useful for measuring and improving productivity across the organization." It recommends measuring the aggregate effect instead. Per-person views still help with load by showing reviewers with too many open requests. For a baseline, the median reviewer in Google's case study reviewed 4 changes a week, and 80% reviewed fewer than 10.

Rubber-stamp approvals

A rubber stamp is an approval with no review behind it, and it looks great on a speed chart. Check for approvals that came at more than 200 lines an hour, counted from when the PR was ready (the rate McIntosh's study used to flag hasty reviews), approvals with no comments on large PRs, and approvals followed by more pushes. GitHub's branch protection can dismiss stale approvals when new commits change the diff, or require the most recent push to be approved by someone other than the person who pushed it.

How AI review changes the numbers

An AI code review tool posts its review within minutes of a PR opening. If those reviews count, time to first review falls to minutes while authors wait just as long for a person. GitHub returns a bot's review like any other, with user.type set to Bot. Filter bots out or report them on their own line. Google's review tool does something similar for its code analyzers, showing their comments next to human ones in a different color.

Comment counts rise with every bot comment, so count bot and human comments separately, and judge the bot's comments by how many lead to a fix, the same change-trigger signal the Microsoft study used. Rework moves earlier when authors fix the bot's findings before a person looks, which is why the script counts commits after the first human review.

cubic's analytics work the same way. Its median wait for first human review leaves out reviews by bots, our own included, and its fix rate counts issues that a later commit fixed, with a note in the docs that this check can be wrong.

The number to protect is the share of merged PRs that a person reviewed. A bot's review doesn't count toward it, and it can fall even as every speed metric improves. Meanwhile, PR volume has grown, and every PR that needs a human approval adds to a reviewer's queue. In the same State of AI coding report, merged PRs per developer doubled from a median of 6 a month in June 2025 to 12 in May 2026.

FAQ

How big of a PR is too big?

Google's guide gives rough limits. It calls 100 lines "usually a reasonable size" and 1,000 lines "usually too large," and it counts files too: "A 200-line change in one file might be okay, but spread across 50 files it would usually be too large." The median change in Google's 2014 to 2016 data was smaller still, at 24 lines modified.

How many commits are in a pull request?

GitHub reports the count in the pull request object's commits field. The PR commits endpoint lists at most 250 of them. For bigger PRs, use the repository's list commits endpoint. Squash merging combines a PR's commits into one commit on the base branch, so take the count from the pull request.

Does GitHub show pull request metrics?

Partly. Pulse, under a repository's Insights tab, lists open and merged pull requests for a period, the last seven days by default, but it doesn't time reviews. If your organization has GitHub Copilot, the repository-level reports in the Copilot usage metrics API give median and 90th percentile times from ready for review to the first human review, from first to final review, and from final review to merge. Otherwise, use the REST API or the issue-metrics action, whose example workflow files a monthly report as a new issue.

How do code review metrics relate to DORA metrics?

DORA splits its five delivery metrics into throughput (change lead time, deployment frequency and failed deployment recovery time) and instability (change fail rate and deployment rework rate). Review time sits inside change lead time, which runs from commit to production. DORA describes the five as lagging indicators for development and delivery practices, and code review is one of those practices.

Table of contents