Roof Replacement Estimate: Building an Honest Cost Model
I built a US roof cost calculator (newroofcalc.com). It is a table-driven estimate engine, and most of the work went into three things that have nothing to do with roofing: unit discipline, interval arithmetic, and deciding what the model should refuse to answer.
Here is the model, and the mistakes it is built to avoid.
Input #1 is usually the wrong unit
Every roofing estimate starts with an area, and the number most people have is the wrong one. 2,000 sq ft of living area is a tax-card number, not a roof.
Roof surface for a simple gable is bigger than the footprint — a 6/12 pitch multiplies it by roughly 1.118. A two-story house is the reverse case: one roof, close to half the living area under it.
So the first thing the UI does is force an explicit choice between "roof surface" and "living area, then convert", and the conversion happens before any dollar appears. Mix those two units and every downstream line is wrong in the same direction — a five-figure error at 2,000 sq ft.
A square is a unit of roof, not of house
1 roofing square = 100 sq ft of roof surface.
That single line is the whole interface between geometry and money. Material tables in this trade are quoted in dollars per square installed, so 20 squares is the geometry, and the dollars live one step further down.
Waste is a shape tax
Crews do not bill the geometric area. They bill area × (1 + waste), and waste comes from the shape of the roof, not its size:
| Roof shape | Waste |
|---|---|
| Shed | 9% |
| Gable | 10% |
| Hip | 15% |
| 9/12 and steeper | +~4 points |
A 2,000 sq ft gable is 20 squares and 22 billable squares. Every downstream cost — material, labor, tear-off — multiplies the 22. This is the one line where a "roughly right" estimate goes off by 3–15%, so it gets its own step and its own row in the output.
The pipeline, in order
The order of operations is the model. Stored prices are installed (material + labor bundled), so region and pitch adjustments have to hit the labor share before the band gets multiplied out:
type Range = readonly [number, number];
const scale = ([lo, hi]: Range, k: number): Range => [lo * k, hi * k];
const add = (a: Range, b: Range): Range => [a[0] + b[0], a[1] + b[1]];
function estimate(job: Job, t: RateTable): Estimate {
const squares = job.roofArea / 100;
const billable = squares * (1 + t.waste[job.shape][job.pitchBand]);
const regionK = t.laborRatio[job.state] ?? 1;
const pitchK = t.pitchLabor[job.pitchBand];
const installed = scale(t.installed[job.material], billable);
const material = scale(installed, t.materialShare[job.material]);
const labor = scale(installed, 1 - t.materialShare[job.material]);
const adjLabor = scale(labor, regionK * pitchK);
const tearOff = job.tearOff
? scale(t.tearOff[job.existingMaterial], billable * job.layers)
: [0, 0] as const;
return {
billable,
total: add(add(add(material, adjLabor), tearOff), t.allowance),
};
}
Four things in that function are the whole lesson:
-
billableis computed once and reused. Tear-off scales with the same billable squares as the install, not with the geometric area. -
regionK * pitchKhitslaboronly. Material stays on the national table. That is why a ZIP change moves the total by hundreds, not thousands. - Waste is applied before the per-square rate, never after. Applying waste to a finished total gets you a number that looks plausible and is wrong.
-
The return is a
Range, and intervals stay intervals. No midpoint anywhere in the pipeline.
Ranges all the way down
The single most common bug in a cost estimator is collapsing a range to its midpoint too early.
If a rate is $411–$666 and you compute (411 + 666) / 2 = 538.5, then scale it, then add a $400–$1,200 allowance that you also averaged to $800, you get one number with a fake decimal — and the real spread, which is the useful part, is gone. Worse, the error compounds: the width of the output is now a function of how many midpoints you took.
Propagating the interval end-to-end is barely more code (scale and add above are the entire algebra) and it gives you the honest output:
- asphalt on 22 billable squares →
$10,300–$17,000 - standing seam on the same 22 →
$41,100–$57,000 - exposed-fastener on the same 22 →
$13,600–$29,500
The other trick that surprises people: the per-sq-ft row is total ÷ the area the user typed, not ÷ billable squares. On the default job that is $5.15–$8.51. Divide the same total by 22 instead of 20 and you get a different unit price — and that is expected, not a rounding bug. Blogs that quote a bare per-sq-ft figure without saying which denominator they used are mixing units, and you cannot tell from the number.
Two knobs, and only two
Pitch and region are the only two multipliers, and both are labor-side:
| Pitch | Labor multiplier |
|---|---|
| 4/12 – 6/12 | 1.00 |
| 2/12 – 4/12 | 1.10 |
| 7/12 – 9/12 | 1.15 – 1.25 |
| 10/12 and steeper | 1.40 – 1.80 |
Region comes from a ZIP → state → wage-ratio lookup. The ratio is from BLS OEWS, occupation Roofers (SOC 47-2181), May 2024 state means against the employment-weighted national mean of $25.51. Two things it is not: it is not what a crew bills per hour, and a state with a suppressed sample falls back to ×1.00 rather than inventing a number.
Tear-off moves with region too. On metal it is a flat $50–$85 per square per layer — $1,100–$1,870 on those 22 squares — while the asphalt row has its own $39–$53. That widely quoted $1–$3 per sq ft tear-off band you see everywhere is built for tile, slate, and multi-layer strip; drop it into a default asphalt job and you overstate haul-off by two or three times.
What the model refuses to answer
The most useful engineering decision was writing down what the table has no row for, and printing that instead of guessing:
- Purlins and deck reinforcement. The rate table does not store it. The output says "not priced on this sheet" rather than adding a padded allowance.
- Service life / annual cost. There is no service-life field, so there is no annual figure. An invented "cost per year" would be the single most quotable number on the site and the least defensible.
- Minimum slope. Manufacturer sheets state one; the table does not store it, so the page prints none.
- Commercial work. Standing seam on a house and standing seam on a store can share a panel name and not share a unit price. The sheet is explicitly residential.
The same logic applies to the gap between the model and the market. Public 2026 guides put a mid-size asphalt replacement near $14,500–$22,000; this engine's clean-rectangle default is $10,300–$17,000. That difference is not padding — it is deck repair, cut-up roofs, penetrations, and schedule, none of which have a row. Labeling the output "a budget estimate, not a bid" is not a legal disclaimer, it is the correct description of what a table-driven model can honestly return.
Where the numbers come from
Provenance is a feature, so /about lists it: the asphalt band is the union of peer calculator tables ($4.11–$5.57 / sq ft) and one cost guide's May 2026 range ($5.09–$6.66 / sq ft), stored as $411–$666 per square. It is not a survey and it is not RSMeans, and the page says so out loud — including links to the sources and to the BLS page.
That matters for a different reason than trust: when the table updates, the diff is auditable. A rate table with unknown provenance can only be replaced wholesale.
Take the range to the inspection
The output of all this is not a price. It is a shopping instrument: a range with named assumptions (clean rectangle, one layer of penetrations, ground access) and a printed list of what sits outside it.
If you are building something similar, the transferable parts are the boring ones — pin the units at the input, keep intervals as intervals, compute the billable quantity once and reuse it, and give every field you don't have the discipline to omit rather than estimate.
The same engine runs the standing-seam and exposed-fastener rows side by side with asphalt on identical inputs; that comparison lives on the metal roof installation cost sheet.
Top comments (1)
The labor-only regional adjustment caught my eye. What evidence made you keep material prices national when the same regional supply chain can affect both inputs?