Most online concrete calculators do the same thing: multiply square feet by one price per square foot, then print your city's name above the result. Change the city and the name changes. The number usually doesn't.
I wanted to know what a slab really costs in a given place, so I built ConcreteCostCalc, a concrete price calculator for U.S. metro areas. It prices labor from federal wage data for 393 metros and shows where every number came from. This post covers the parts that turned out to be interesting to build: the cost formula, the public data behind it, the ways that data fails without telling you, and how the site stays a static build. If you just want to try it, the concrete slab cost calculator is the one most of this post is about.
The formula: a fixed fee plus a marginal rate
Area × price per square foot works fine for a driveway. It falls apart on a small pad, because a big chunk of what a contractor charges doesn't depend on size at all. Getting a crew, a truck and forms to the site costs about the same for 40 sq ft as for 400.
So every estimate is:
total = mobilization + area × (material + labor + add-ons per sq ft)
Here's the core of it, stripped down to one fixed amount and a marginal rate:
Labor scales with area. Going from 4 in to 6 in adds concrete, but a crew doesn't take 50% longer to place and finish the same slab.
Here is what the fixed fee does to the price per square foot. These are national ranges for a 4 in slab with no add-ons, straight from the model:
| Slab | Total | Per sq ft |
|---|---|---|
| 40 sq ft (8×5 ft) | $1,120 to $2,230 | $28.03 to $55.72 |
| 100 sq ft (10×10 ft) | $1,460 to $2,890 | $14.56 to $28.94 |
| 400 sq ft (20×20 ft) | $3,010 to $5,990 | $7.53 to $14.97 |
| 1,000 sq ft (25×40 ft) | $6,210 to $12,350 | $6.21 to $12.35 |
Take the 400 sq ft rate and apply it to the 8×5 ft pad and you get $301 to $599. The model's range is about 3.7 times that, almost entirely because of the setup cost. A flat per-square-foot calculator gets small jobs badly wrong.
Making the price depend on where you live
Labor is the part that varies most by place, and it's also the part with the best public data. The Bureau of Labor Statistics publishes Occupational Employment and Wage Statistics (OEWS) every year, including wages for cement masons and concrete finishers (SOC 47-2051) in each metro area. The model turns that wage into an installed labor rate with a calibrated multiplier for overhead and margin.
To get from a visitor's ZIP code to a metro, I use HUD's ZIP-to-CBSA crosswalk. Two details matter:
- A ZIP code can straddle several metros. The crosswalk splits it into rows with a ratio for each, and I take the row with the largest share.
- Roughly 4 in 10 U.S. ZIP codes aren't in any of the 393 metros. Those get the national average, and the calculator says so instead of guessing.
Materials are coarser. The anchor is the 2022 Economic Census, brought forward to the current month with the BLS Producer Price Index for ready-mix concrete. Census only publishes those figures nationally (state-level cells are suppressed for confidentiality), so materials only vary across the country plus four broad regions.
Put together, the same 20×20 ft, 4 in slab comes out like this:
| Where | Estimate |
|---|---|
| Houston | $2,750 to $5,500 |
| U.S. national average | $3,010 to $5,990 |
| San Francisco | $3,370 to $7,430 |
Job type matters too. At the same 20×20 ft, a driveway runs $3,230 to $6,410 nationally, because it gets a higher setup cost, a thicker gravel base by default (5 in instead of 4) and a bit more waste allowance. That's why the site has a separate page for what a concrete driveway costs instead of reusing the slab numbers.
Data pitfalls that fail silently
None of these throw an error. They just give you a wrong number.
OEWS mixes flags into numeric columns. A wage cell can hold * (wage not available), ** (employment not available) or # (the value is above the top code). Parse those as numbers and you get NaN or a wildly wrong figure. I store them as a nullable number plus a separate flag.
Not every metro has the occupation. Cement mason wages exist for 336 of the 393 metros (85.5%). For the rest, the model falls back to the state level or to a related occupation, and the breakdown says which one happened.
bls.gov blocks scripts, api.bls.gov doesn't. The main site sits behind bot protection that rejects curl even with a browser user agent. The API host works fine. Also, the OEWS file URLs embed the year with no "latest" alias, and the release month has drifted over the years, so don't hardcode it.
The Producer Price Index is an index. It has no dollar units. National and regional series also use different base periods, so if you store them in one table, store the base period with each series. I also check whether a series has stopped moving: two regional series once sat at exactly the same value for five months in a row.
Census data requests are picky about query parameters. The data site sits behind a web application firewall, and extra parameters have to be packed into a single codeset= value. Spread them across separate parameters and the request gets rejected.
Labeling every number by how solid it is
Each input carries a provenance record:
export type ParamLevel = 'sourced' | 'calibrated' | 'assumed';
export interface Provenance {
level: ParamLevel;
source: string; // 'BLS OEWS' | 'BLS PPI' | ...
ref?: string; // 'SOC 47-2051'
asOf: string; // when the data describes: '2025-05'
geo?: 'metro' | 'state' | 'region' | 'national';
fellBackFrom?: 'metro' | 'state' | 'region' | 'national';
}
In the UI those show up as SOURCED, CALIBRATED and ESTIMATE badges on every line of the breakdown, with the source and data date. The setup fee, for example, is labeled ESTIMATE: there's no public dataset for it, so it's my own figure.
This was partly a product decision and partly a requirement. Once you've extrapolated or multiplied government data, you can't present the result as if it came straight from the agency. Labeling every input is the honest way to show the difference.
Rounding line items so they add up
A cost breakdown that doesn't add up looks broken, even when every line is right. Round each line to the nearest $10 on its own and the lines drift away from the total. The fix is the largest remainder method: floor every line to the step, then give the leftover steps to the lines that lost the most.
In a random test of 20,000 breakdowns, rounding each line separately missed the total 38% of the time. The allocation missed it zero times.
Static pages, one dynamic route
The site is built with Astro and deployed to Cloudflare. Every page is prerendered, and the rate data ships as a static JSON file, so the calculator runs in the browser. The only server route is a small geo lookup that maps a ZIP code (or the visitor's approximate location) to a metro.
Every page renders the national numbers first. Search engines and new visitors see the same HTML, and the local price replaces it after the page loads. That keeps the page cacheable and avoids showing crawlers something different from what people see.
One build bug is worth knowing about if you use Astro on Cloudflare. With nodejs_compat enabled and a compatibility_date of 2025-10-01 or later, prerendered pages built as a 15-byte file containing [object Object], and the build reported success (withastro/astro#18009, fixed in astro 7.3.3). I now run a script after every build that fails it if any page is suspiciously small.
What it still gets wrong
- Past about 1,000 sq ft, the estimate likely runs low. The public pricing evidence gets thin at that scale.
- The setup fee is my own calibrated estimate. No public dataset covers it.
- Wage data is published once a year and runs about a year behind.
- Material prices only go down to four broad regions.
Every number is a planning range. Before anyone builds, they should get a written quote.
The cost model is open source on GitHub (MIT), with its tests and a sample rate file, if you want to check the math or reuse it.
Have you built something that turns public datasets into prices? I'd like to hear which data traps you ran into.
Top comments (0)