Tools

PR split cost

/tools/pr-split-cost/

Paste your PRs' sizes and review waits. Get how much the wait grows with size, and what splitting a change into k PRs costs in waiting, reviewed one after another, side by side, or in one sitting.

How it works

PR split cost#

What it does#

“Keep pull requests small” is good advice until each small PR waits its turn for review. This tool measures how much the wait for a first review grows with a PR’s size on your own repository, and prices splitting a change into k PRs, reviewed one after another, side by side, or by one reviewer in one sitting.

Paste one line per merged PR: its size in lines changed, and the hours from ready-for-review to the first review by someone other than its author. The default is grafana/grafana’s 857 most recently created merged PRs with such a review. Then press Run.

OutputWhat it is
The three waysExpected waiting for a first review for your k pieces, reviewed each of the three ways, against one PR of the whole change, slowest first. Fit says is the multiple if size drives the pickup as the fit says; If size does not matter is the other end. The truth is somewhere between the two. Your pick is named in its row. “In one sitting” is shown muted rather than green, because its small number rests on an assumption about how the reviewer works
How far to trust itRight under the comparison: how much of the variation in wait size explains, r squared, with r and the number of PRs. Below r = 0.3 it says the multiples are directional only
The fit’s numbersElasticity e, the slope of a straight line through log(wait) against log(size), with a 95% bootstrap range; what doubling a PR does to its wait, 2^e; and the median wait on the fit for one PR of the whole change, with the mean beside it
ChartsThe waiting multiple against k for the three ways, and the pasted PRs with the fitted line, both axes logarithmic
What each way assumes, and markdownWhat each way rests on, what is measured, and a markdown version of the comparison to paste into a PR description or a design doc

How to use it: an example#

Leave the default PRs in place: grafana’s 857. Keep The change at 800 lines, Split into at 8 PRs and Reviewed at “One after another”, and press Run. The answer is the three ways, slowest first:

text
8 PRs instead of one of 800 lines: the wait for a first review, as a multiple of the one PR's
Reviewed Fit says If size does not matter
One after another (your pick) 4.76x 8x
Side by side 3.10x 5.12x
In one sitting 0.59x 1x

and right under it, how far to trust that:

text
Directional only: size explains 3.5% of the variation in wait across 857 PRs (r = 0.19). A correlation, not a cause.

then e = 0.25 (95% bootstrap range 0.16 to 0.34), doubling a PR multiplying its wait by 1.19, and one 800-line PR waiting a median of 2.1 h on the fit, with a mean of 34.2 h.

In plain words: on grafana, if the eight pieces go through the queue one at a time, the feature waits about five to eight times as long for review as one big PR would have. Opening them all at once helps, three to five times. Only a reviewer who takes all eight together brings it back to one PR’s wait or less, and that is a way of working the team has to agree on, not something these PRs show. Size explains very little of any one PR’s wait, which is why both ends are on the screen.

Now pick “In one sitting” and press Run again: the numbers stay, and “(your pick)” moves to the muted row, 0.59x on the fit and 1x, the same as one PR, if size does not matter. Try your own repository by pasting split_cost.py --csv output in place of the default.

The three ways#

  • One after another. Each piece waits for its own first review, and the next isn’t opened until the last is merged. Each piece is k times smaller, so it waits (1/k)^e as long, and there are k of them: k^(1-e) times one PR’s wait. At e = 0.25 that is 4.76 for eight pieces. This is exact in expectation under the fit, whatever the scatter, because expected waits add. If size has no effect, it is k.
  • Side by side. All k open at once, each picked up independently, and the change waits for the slowest. The slowest of k depends on how scattered the waits are, which e doesn’t say, so the tool computes the expected largest of k exactly from the fit’s own residuals. On the three repositories I measured, eight pieces side by side wait two to three and a half times as long as one PR, and about five times if size has no effect. Real pickups of one author’s PRs opened together are not independent, and the dependence can go either way: PRs picked up on the same good day make the slowest of k smaller, and a reviewer who works through them one at a time makes it larger, closer to one after another. So this is neither an upper nor a lower bound; it is the independent case.
  • In one sitting. One reviewer takes all k together, starting as soon as they would start one small piece. The change then waits for one pickup of a PR k times smaller: k^(-e), under 1. If size has no effect, it is one pickup like any other, the same as one PR. So this way is no worse than one PR, and better only if size drives the pickup. The data doesn’t measure it, which is why it is shown muted.

If one person reviews everything, it depends how they work through the pieces: taking them together is “in one sitting”; treating each as its own item in their queue, one at a time, is closer to “one after another”.

What it assumes#

  • Correlation, not cause. The fit describes your history; it doesn’t promise what your next PR will do. Anything that affects both a PR’s size and how fast it is picked up shows up in e. That is why the tool shows both ends.
  • One line on log axes. Size explains little of the wait on the repositories I’ve measured, r = 0.12 to 0.27. The data says how much it leaves unexplained, not what explains the rest.
  • Only the wait for a first review. Rework, the second round, CI and merge conflicts are not counted, and a stack of PRs adds rebasing work this doesn’t price.
  • Waits under three minutes count as three minutes, so a log can be taken. Most of these are drafts reviewed before their author marked them ready. They can move e: on grafana, leaving them out takes it from 0.25 to 0.16.
  • Mean waits. The multiples are of expected (mean) waits, because waits that follow one another add. Medians keep the same order of the three ways, with much larger multiples for the first two.

The script that fetches and fits the same data for any repository is split_cost.py, in the companion code for the article this tool came from. Its --csv output pastes straight in.

Source and references

index.ts358 lines, TypeScript
TypeScript
/**
* PR split cost: what splitting a change into k pull requests costs in waiting for review.
*
* Paste (size, wait) pairs for your own merged PRs: lines changed, and hours from ready-for-review to the first
* review by someone else. The tool fits a straight line to log(wait) against log(size). Its slope e is the
* elasticity: double a PR and its wait is multiplied by 2^e. Busy open-source repositories come out between about
* 0.2 and 0.45: the wait grows much more slowly than size.
*
* From e, three ways a change split into k PRs can be reviewed, each against one PR of the whole change:
*
* - one after another: each piece waits its own pickup. The expected total is k x (1/k)^e = k^(1-e) times one
* PR's, exactly, under the fit, whatever the scatter around it, because expected waits add.
* - side by side, independently: the change waits for the slowest of k pickups. That depends on the scatter, not
* just on e, so it is computed exactly from the fit's own residuals: the expected largest of k draws.
* - in one sitting: one reviewer takes all k together, so the change waits for one pickup of a PR k times smaller,
* k^(-e). That assumes the reviewer starts the stack as soon as they would start one small piece.
*
* All three read e as if size drove the wait. The tool also prints the other end, as if size had no effect: k, the
* slowest of k ordinary pickups, and 1. The truth for a team is somewhere between.
*
* ⚠️ What it is not: a causal model. The fit is a correlation, usually a weak one, and small PRs are often small
* fixes by maintainers, picked up fast for that reason. The tool reports r and how much of the variation in wait size
* explains, so the weakness is on the screen beside the answer.
*
* The result leads with the three ways side by side at the reader's k, slowest first, at both ends. Right under it is
* how weak the fit is, because a 4.76x that rests on a fit explaining 3.5% of the variation must not read as a
* measurement. Then the fit's numbers, the charts, what each way assumes, and a markdown copy for a PR description.
* "In one sitting" is drawn muted, never green: its small multiple rests on an assumption about the reviewer, not on
* the data. Nothing else is toned: no limit the reader typed decides that a multiple is bad.
*
* Seeded: the bootstrap and the draws use mulberry32 from a constant, so the same input always gives the same output.
*/
import type { Field, Output, Series, Tool } from "@toolbench/sdk";
type Input = { pairs: string; source: string; elasticity: number; lines: number; split: number; review: string };
class InputError extends Error {
readonly input: string;
readonly at: number | undefined;
readonly len: number | undefined;
constructor(message: string, input: string, at?: number, len?: number) {
super(message);
this.name = "InputError";
this.input = input;
this.at = at;
this.len = len;
}
}
const FLOOR_H = 0.05;
const BOOTSTRAPS = 1000;
const MIN_PAIRS = 10;
function rng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** One pair per line: size and hours, separated by a comma, tab, semicolon or spaces. A header line is skipped. */
function parse(text: string): { size: number; wait: number }[] {
const out: { size: number; wait: number }[] = [];
let offset = 0;
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const at = offset;
offset += line.length + 1;
const trimmed = line.trim();
if (trimmed === "") continue;
const cells = trimmed.split(/\s*[,;\t]\s*|\s+/);
if (out.length === 0 && i === lines.findIndex((l) => l.trim() !== "") && cells.some((c) => Number.isNaN(Number(c)))) {
continue; // a header such as size,wait_hours
}
const start = at + line.indexOf(trimmed[0]!);
if (cells.length < 2) throw new InputError(`Line ${i + 1} has one number; each line needs a size and a wait.`, "pairs", start, trimmed.length);
const size = Number(cells[0]);
const wait = Number(cells[1]);
if (!Number.isFinite(size) || !Number.isFinite(wait) || cells[0] === "" || cells[1] === "") {
throw new InputError(`Line ${i + 1} isn't two numbers: "${trimmed.slice(0, 30)}".`, "pairs", start, trimmed.length);
}
if (size < 0 || wait < 0) throw new InputError(`Line ${i + 1} has a negative number.`, "pairs", start, trimmed.length);
out.push({ size: Math.max(1, size), wait: Math.max(FLOOR_H, wait) });
}
return out;
}
type Fit = { e: number; a: number; r: number };
function fitLogs(xs: Float64Array, ys: Float64Array, pick?: Uint32Array): Fit {
const n = pick ? pick.length : xs.length;
let sx = 0;
let sy = 0;
for (let i = 0; i < n; i++) {
const j = pick ? pick[i]! : i;
sx += xs[j]!;
sy += ys[j]!;
}
const mx = sx / n;
const my = sy / n;
let sxx = 0;
let sxy = 0;
let syy = 0;
for (let i = 0; i < n; i++) {
const j = pick ? pick[i]! : i;
const dx = xs[j]! - mx;
const dy = ys[j]! - my;
sxx += dx * dx;
sxy += dx * dy;
syy += dy * dy;
}
const e = sxy / sxx;
return { e, a: my - e * mx, r: syy > 0 ? sxy / Math.sqrt(sxx * syy) : 0 };
}
/**
* Expected largest of k independent draws from `sorted` (ascending), over their mean. Exact, not sampled: the largest
* of k is the i-th smallest with probability (i/n)^k - ((i-1)/n)^k. No randomness, so the answer can't depend on what
* else was computed first, which a shared sampler did.
*/
function slowestOf(k: number, sorted: Float64Array, mean: number): number {
const n = sorted.length;
let total = 0;
let below = 0;
for (let i = 1; i <= n; i++) {
const upto = (i / n) ** k;
total += sorted[i - 1]! * (upto - below);
below = upto;
}
return total / mean;
}
const median = (sorted: Float64Array) => {
const n = sorted.length;
return n % 2 ? sorted[(n - 1) / 2]! : (sorted[n / 2 - 1]! + sorted[n / 2]!) / 2;
};
/** A waiting multiple: "4.76x", "8x", "1x". */
const times = (v: number) => {
const r = Math.round(v * 100) / 100;
return Number.isInteger(r) ? `${r}x` : `${r.toFixed(2)}x`;
};
const hours = (h: number) => (h >= 48 ? `${(h / 24).toFixed(1)} days` : h >= 1 ? `${h.toFixed(1)} h` : `${Math.round(h * 60)} min`);
type Way = "serial" | "parallel" | "sitting";
const WAY_NAME: Record<Way, string> = { serial: "One after another", parallel: "Side by side", sitting: "In one sitting" };
const WAY_RESTS_ON: Record<Way, string> = {
serial: "Each piece waits for its own first review, and the next opens when the last one merges.",
parallel: "All the pieces open at once and are picked up independently, so the change waits for the slowest.",
sitting: "One reviewer takes them all together, as soon as they would start one small piece. That is an assumption about how they work, not a measurement.",
};
export default {
run({ pairs, source, elasticity, lines, split, review }: Input, ctx) {
try {
if (!Number.isInteger(split) || split < 1 || split > 50) throw new InputError("Split into a whole number of PRs, from 1 to 50.", "split");
if (!(lines >= 1)) throw new InputError("The change needs at least one line.", "lines");
if (!["serial", "parallel", "sitting"].includes(review)) throw new InputError("Pick how the pieces are reviewed.", "review");
const way = review as Way;
const metrics: Field[] = [];
let evidence = "";
const charts: Output[] = [];
let e: number;
let lo: number | undefined;
let hi: number | undefined;
let residuals: Float64Array | undefined;
let residualMean = 1;
let residualMedian = 1;
let waits: Float64Array | undefined;
let waitMean = 1;
let a = 0;
let fitNote = "";
if (source === "fit") {
const data = parse(pairs);
if (data.length < MIN_PAIRS) {
throw new InputError(`Paste at least ${MIN_PAIRS} PRs to fit; there ${data.length === 1 ? "is" : "are"} ${data.length}.`, "pairs");
}
const xs = Float64Array.from(data, (d) => Math.log(d.size));
const ys = Float64Array.from(data, (d) => Math.log(d.wait));
if (Math.max(...xs) - Math.min(...xs) < 1e-9) throw new InputError("Every PR is the same size, so there is no slope to fit.", "pairs");
const f = fitLogs(xs, ys);
e = f.e;
a = f.a;
const next = rng(1);
const pick = new Uint32Array(data.length);
const es: number[] = [];
for (let b = 0; b < BOOTSTRAPS; b++) {
ctx?.signal?.throwIfAborted();
for (let i = 0; i < pick.length; i++) pick[i] = Math.floor(next() * data.length);
es.push(fitLogs(xs, ys, pick).e);
}
es.sort((p, q) => p - q);
lo = es[Math.floor(0.025 * BOOTSTRAPS)]!;
hi = es[Math.ceil(0.975 * BOOTSTRAPS) - 1]!;
residuals = Float64Array.from(data, (_, i) => Math.exp(ys[i]! - (a + e * xs[i]!))).sort();
residualMean = residuals.reduce((s, v) => s + v, 0) / residuals.length;
residualMedian = median(residuals);
waits = Float64Array.from(data, (d) => d.wait).sort();
waitMean = waits.reduce((s, v) => s + v, 0) / waits.length;
fitNote = `e = ${e.toFixed(2)} from ${data.length} PRs, r = ${f.r.toFixed(2)}`;
const explained = (f.r * f.r * 100).toFixed(1);
// Weak below r = 0.3, where size accounts for under a tenth of the variation: then the multiples only point.
evidence =
Math.abs(f.r) < 0.3
? `Directional only: size explains ${explained}% of the variation in wait across ${data.length} PRs (r = ${f.r.toFixed(2)}). A correlation, not a cause.`
: `Size explains ${explained}% of the variation in wait across ${data.length} PRs (r = ${f.r.toFixed(2)}). A correlation, not a cause.`;
const typical = Math.exp(a + e * Math.log(lines));
metrics.push(
{ label: "Elasticity e", value: e.toFixed(2), note: `95% bootstrap range ${lo.toFixed(2)} to ${hi.toFixed(2)}` },
{ label: "Doubling a PR", value: `${(2 ** e).toFixed(2)}x the wait` },
{
label: `Median wait, one PR of ${lines} lines`,
value: hours(typical * residualMedian),
note: `mean ${hours(typical * residualMean)}${residualMean > 1.1 * residualMedian ? ", pulled up by the slowest few" : ""}`,
},
);
// The scatter and the line, both on log axes, because that is where the fit is a line.
const sizes = data.map((d) => d.size);
const minS = Math.min(...sizes);
const maxS = Math.max(...sizes);
const lineX = [minS, Math.sqrt(minS * maxS), maxS];
charts.push({
kind: "series",
chart: {
xLabel: "PR size, lines changed",
yLabel: "Hours to first review",
xScale: "log",
yScale: "log",
x: lineX,
series: [
{ label: "Pasted PRs", shape: "points", x: sizes, points: data.map((d) => d.wait) },
{ label: `Fit, wait ∝ size^${e.toFixed(2)}`, shape: "line", x: lineX, points: lineX.map((s) => Number(Math.exp(a + e * Math.log(s)).toFixed(3))) },
],
readout: { mode: "point" },
},
});
} else {
if (!(elasticity >= 0 && elasticity <= 1)) throw new InputError("An elasticity between 0 and 1, please.", "elasticity");
e = elasticity;
fitNote = `e = ${e.toFixed(2)}, as given`;
evidence = `e = ${e.toFixed(2)}, typed in rather than fitted, so nothing here says how well size predicts the wait.`;
metrics.push({ label: "Doubling a PR", value: `${(2 ** e).toFixed(2)}x the wait` });
}
const serial = (k: number) => k ** (1 - e);
const sitting = (k: number) => k ** -e;
const parallel = (k: number) => (residuals ? sitting(k) * slowestOf(k, residuals, residualMean) : undefined);
const onFit = (w: Way, k: number) => (w === "serial" ? serial(k) : w === "sitting" ? sitting(k) : parallel(k));
// The other end: size has no effect on the pickup, so every piece waits like an ordinary PR.
const noEffect = (w: Way, k: number) => (w === "serial" ? k : w === "sitting" ? 1 : waits ? slowestOf(k, waits, waitMean) : undefined);
// "In one sitting" rests on an assumption about the reviewer, so it is never shown as good news.
const tone = (w: Way) => (w === "sitting" ? "muted" : "normal");
const k = split;
const pieces = `${k} PR${k === 1 ? "" : "s"}`;
// The three ways at the reader's k, slowest first, with the unpriceable one last. The pick is named in words.
const ways = (["serial", "parallel", "sitting"] as Way[]).sort((p, q) => (onFit(q, k) ?? -1) - (onFit(p, k) ?? -1));
const comparison: Output = {
kind: "table",
role: "comparison",
caption: `${pieces} instead of one of ${lines} lines: the wait for a first review, as a multiple of the one PR's`,
columns: [
{ label: "Reviewed", wrap: "prose", rowHeader: true },
{ label: source === "fit" ? "Fit says" : `At e = ${e.toFixed(2)}`, align: "end", mono: true, wrap: "none" },
{ label: "If size does not matter", align: "end", mono: true, wrap: "none" },
],
rows: ways.map((w) => {
const v = onFit(w, k);
const b = noEffect(w, k);
const t = tone(w);
return [
{ text: `${WAY_NAME[w]}${w === way ? " (your pick)" : ""}`, tone: t },
{ text: v === undefined ? "Paste PRs" : times(v), mono: true, tone: t },
{ text: b === undefined ? "Paste PRs" : times(b), mono: true, tone: t },
];
}),
};
// Waiting multiple against k, for the three ways, with 1 (one PR) as the line to beat.
const ks = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32].filter((v) => v <= Math.max(16, k * 2));
if (!ks.includes(k)) ks.push(k);
ks.sort((p, q) => p - q);
const lines3: Series[] = [
{ label: "One after another", shape: "line", markers: true, points: ks.map((v) => Number(serial(v).toFixed(3))) },
...(residuals ? [{ label: "Side by side", shape: "line" as const, markers: true, points: ks.map((v) => Number(parallel(v)!.toFixed(3))) }] : []),
{ label: "In one sitting", shape: "line", markers: true, points: ks.map((v) => Number(sitting(v).toFixed(3))) },
];
charts.unshift({
kind: "series",
chart: {
xLabel: "Split into k PRs",
yLabel: "Waiting for a first review, against one PR",
x: ks,
series: lines3,
thresholds: [{ y: 1, label: "one PR" }],
annotations: [{ x: k, label: `${k} PRs` }],
},
});
// What each way assumes, as prose under the numbers rather than a column in the comparison.
const method: Output = {
kind: "fields",
role: "method",
fields: [
...(["serial", "parallel", "sitting"] as Way[]).map((w): Field => ({ label: WAY_NAME[w], value: WAY_RESTS_ON[w], group: "What each way assumes", mono: false })),
{ label: "Measured", value: "Hours from ready for review to the first review by someone else. Not the time to merge.", group: "What it counts", mono: false },
],
};
// For a PR description or a design doc, where a tab-separated table doesn't render.
const md = [
`**Splitting a change of ${lines} lines into ${pieces}** (${fitNote})`,
"",
"| Reviewed | If size drives the pickup | If size has no effect |",
"|---|---|---|",
...(["serial", "parallel", "sitting"] as Way[]).map((w) => {
const v = onFit(w, k);
const b = noEffect(w, k);
return `| ${WAY_NAME[w]} | ${v === undefined ? "needs PRs" : times(v)} | ${b === undefined ? "needs PRs" : times(b)} |`;
}),
"",
"Expected waiting for a first review, against one PR of the whole change. A correlation, not a cause. In one sitting assumes one reviewer takes every piece together.",
].join("\n");
const parts: Output[] = [
comparison,
// Plain text, not an answer: the caveat belongs beside the numbers, not louder than them.
{ kind: "text", text: evidence },
{ kind: "fields", role: "metrics", fields: metrics },
...charts,
method,
{ kind: "code", lang: "markdown", source: md },
];
return { kind: "group", parts };
} catch (error) {
if (error instanceof InputError) {
return {
kind: "error",
message: error.message,
input: error.input,
...(error.at !== undefined ? { at: error.at } : {}),
...(error.len !== undefined ? { len: error.len } : {}),
};
}
throw error;
}
},
} satisfies Tool<Input>;

Related posts

2 items
  • Oct 13, 2026 26 min read Software craft

    Code review economics

    On three busy repositories the wait for a first review grows much more slowly than a PR's size, so a change split into eight PRs reviewed one after another waits three to eight times as long. Opening them side by side helps less than it looks, and only one reviewer taking them together is no worse than one PR. The measurement, the rule that falls out of it, and a tool to run it on your own PRs.

    The unwritten rulescraftteamsstatistics
  • Software craft

    Code review economics

    On three busy repositories the wait for a first review grows much more slowly than a PR's size, so a change split into eight PRs reviewed one after another waits three to eight times as long. Opening them side by side helps less than it looks, and only one reviewer taking them together is no worse than one PR. The measurement, the rule that falls out of it, and a tool to run it on your own PRs.

    craftteams
    Oct 2026
  • Series

    The unwritten rules

    Habits most codebases rely on without ever stating them.

    8 posts