Area price is the wrong formula: building a concrete cost calculator from public wage data
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:
{% gist gist.github.com file=estimate.ts %}
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.
{% gist gist.github.com file=allocate.ts %}
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.
Mentions
The @astrodotbuild LLM factory works great. Found a bug, clanker wrote a simple repro + short description, I then (manually) filed a ticket. Their bot wrote a fix in ~20 mins. Few days later, after @matthewcp approved the changes, fix was merged in ~3 h. astro#18507
Building a blog shouldn't feel like building a whole app. 🌐 Astro is designed for content-focused sites, with Markdown and UI framework support. Great for portfolios and docs. 🚀 withastro/astro #WebDev #浇朋友 #蓝朋友 #互关 #fo
搭博客还要先搞一整套复杂前端?🌐 Astro 适合内容型网站,支持 Markdown 和多种前端组件。建作品集或文档站可以试试。🚀 withastro/astro #WebDev #浇朋友 #蓝朋友 #互关 #fo
Wow! The Content Mappers API in TypeScript 7.1 lets you get a TypeScript representation of any file (as long as you've registered the relevant content mapper package). And the one for Astro (and many others) has already been merged! astro#17835
Happy spooky season @astro.build It wasn't immediately obvious to me why Houston had a bone... github.com/withastro/astro/blob/c75e89711a4935a0b… At least now I know it's a tie 💀
週刊プログラミングニュース|Deno・Python・OSSと開発ツール【2026-W42/10月9〜15日】 週刊プログラミングニュース|2026年10月9日〜15日 | 2026-W42 最初にDeno、Python、Bun、GitHub Actions、.NET、シークレット保護。その後、OSS・フレームワーク、ライブラリー・データベース、IDE・開発プラットフォームを章ごとに整理します。 対象期間:10月9日00:00〜15日23:59 JST。時刻を確認できた発表はJSTへ換算し、日付のみの資料は公式表記日を採用しています。境界時刻が未確認の候補は確定分と区別しました。 正式版、プレビュー、今後の予定を区別しています。Dockerは修正済み問題の開示、Nodeの10月21日予定分は未提供の修正予告です。 映像は公式情報に基づく解説図で、実機デモではありません。性能値は発表元の測定で、独自の実測ではありません。 日本語音声・日英字幕。 チャプター 00:00 週刊プログラミングニュース 00:28 DenoチームがCloudflareへ 01:50 Python 3.15が正式公開 03:18 Bun 1.4.3に型チェッカー 04:30 Actionsにワークフローロック 05:38 .NETは安定版更新とRC2 07:00 GitHubのシークレット検出拡大 07:59 Next.jsのセキュリティ更新 09:28 Express・Fastify・Micronaut 10:57 フロントエンドの安定版とRC 12:16 Astro・Rolldown・Laravel 13:21 Hydrogenがライブラリーへ 14:14 Pydantic・NumPy・FastAPI 15:59 PgBouncerとpsqlODBC 17:25 SQLite 3.54.0 18:38 Ruby 4.1のプレビュー 19:39 Node.js更新と次のセキュリティ修正 20:51 VS CodeとVisual Studio 22:07 Cursor Originのアプリ連携 22:54 GitLabの緊急修正 23:57 Docker Hubの修正済み問題の開示 25:04 Lambdaの新しい実行モデルと接続維持 26:27 今週の確認リスト 公式出典 deno.com blog.python.org bun.com github.blog devblogs.microsoft.com github.blog devblogs.microsoft.com github.com/dotnet/core/blob/main/release-notes/10… nextjs.org github.com/expressjs/express/releases/tag/v5.3.0 github.com/fastify/fastify/releases github.com/fastify/fastify/security/advisories/GH… micronaut.io micronaut.io github.com/angular/angular/releases/tag/v22.2.3 github.com/vuejs/core/releases github.com/vuejs/core/blob/main/CHANGELOG.md github.com/vuejs/core/blob/minor/CHANGELOG.md github.com/nuxt/nuxt/releases/tag/v4.6.1 github.com/sveltejs/kit/releases/tag/@sveltejs%2F… github.com/withastro/astro/releases/tag/astro@7.4… github.com/rolldown/rolldown/releases/tag/v1.2.14 github.com/laravel/framework/releases/tag/v13.36.0 github.com/laravel/framework/releases shopify.dev github.com/pydantic/pydantic/releases/tag/v2.14.0 pydantic.dev github.com/pydantic/pydantic/releases github.com/numpy/numpy/releases/tag/v2.5.4 github.com/fastapi/fastapi/releases/tag/0.143.1 github.com/fastapi/fastapi/releases/tag/0.143.2 pgbouncer.org github.com/pgbouncer/pgbouncer/releases/tag/pgbou… postgresql.org sqlite.org ruby-lang.org nodejs.org nodejs.org nodejs.org openwall.com code.visualstudio.com learn.microsoft.com cursor.com docs.gitlab.com docker.com aws.amazon.com developers.cloudflare.com
Release notes oficiales de Astro 7.4.0 (15 oct 2026): github.com/withastro/astro/releases/tag/astro%407… Ojo: el prerender paralelo gasta más memoria (puedes bajar workers) y el mínimo de Node sube a 22.22.0.
Area price is the wrong formula: building a concrete cost calculator from public wage data 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: {% gist gist.github.com file=estimate.ts %} 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. {% gist gist.github.com file=allocate.ts %} 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.