CDC PLACES¶
Small-area estimates of 49 health and social measures for every US county, place, census tract and ZIP Code tabulation area.
CDC's PLACES data portal has seven pages. This module mirrors all seven:
| Category | categoryid |
Measures | Source |
|---|---|---|---|
| Health Outcomes | HLTHOUT |
12 | BRFSS |
| Prevention | PREVENT |
7 | BRFSS |
| Disability | DISABLT |
7 | BRFSS |
| Health-Related Social Needs | SOCLNEED |
7 | BRFSS |
| Health Risk Behaviors | RISKBEH |
4 | BRFSS |
| Health Status | HLTHSTAT |
3 | BRFSS |
| Non-Medical Factors | SDOH |
9 | 5-year ACS |
Health Outcomes covers arthritis, current asthma, high blood pressure, cancer,
coronary heart disease, COPD, depression, diabetes, high cholesterol, obesity,
stroke and all teeth lost. Disability covers hearing, vision, cognitive,
mobility, self-care and independent-living disability, plus any disability.
data/processed/places/measures.csv is the full catalog.
The first six categories are one CDC product — PLACES proper, modeled from BRFSS — and the seventh is a separate one, "Non-Medical Factor Measures", derived from the American Community Survey. Eight Socrata datasets in all. The two families share a measure catalog but keep separate fact tables; see Two families for why they cannot merge.
That portal is a viewer built on ArcGIS, not a data source — it has nothing to
scrape. The authoritative tables live on data.cdc.gov, and that is what this
module reads.
Why PLACES lives in Dolt, not git¶
| Geography | PLACES ID | Rows | Export | Non-Medical ID | Rows |
|---|---|---|---|---|---|
| County | swc5-untb |
229,298 | 53 MB | i6u4-y3g4 |
28,287 |
| Place | eav7-hnsx |
2,150,438 | ~460 MB | edkk-ze78 |
268,389 |
| Census tract | cwsq-ngmh |
3,047,284 | 694 MB | e539-uadk |
751,509 |
| ZCTA | qnzd-25i4 |
1,171,563 | 224 MB | bumh-rgsq |
291,024 |
Together that is ~7.9M rows and ~1.7 GB. The census-tract file alone is past
GitHub's 100 MB hard limit, and data/ in this repo is already 291 MB with a
131 MB .git. Committing this would break the repository.
So PLACES is mirrored into the public Dolt database
fartbagxp/cdc-places
— free at any size, versioned and diffable the way this project already thinks
about data — and only small derived slices are committed here.
The mirror is ours, not CDC's. DoltHub is a commercial service run by DoltHub Inc., unaffiliated with CDC.
fartbagxp/cdc-placesis a copy this project maintains; CDC neither publishes nor endorses it. For authoritative data, citedata.cdc.govand the Socrata dataset IDs above.
Two families¶
The portal's seven pages are two CDC products, and they do not merge cleanly.
The six BRFSS categories share measurement; Non-Medical Factors gets
nmf_measurement, nmf_location and nmf_location_population. What forced
the split, all measured against the full county exports:
- Different geography vintage. ACS 2017-2021 still uses Connecticut's
retired county FIPS
09001—09015; PLACES 2025 uses the new planning regions09110—09190. Same land, different units. A county map built from one family's FIPS will have holes in Connecticut for the other. This is the one difference that can produce a wrong chart rather than an ugly one. - Different county names.
Autauga Countyhere,Autaugain PLACES, on 3,131 of the 3,133 counties both families cover. Harmless when joining on FIPS, visible in labels. - Different uncertainty column. A margin of error, not low/high confidence limits.
- Different version key. An ACS period string (
2017-2021), not a release year — which is also why there are two ledger tables,release_metaandnmf_release_meta. - Different value semantics. One
Data_Value_Type(Percentage) rather than crude and age-adjusted, so it is not part of the fact table's key. - Different population denominators. Jefferson County, AL is 662,895 in
PLACES 2025 and 672,550 in ACS 2017-2021. Both are correct for their vintage,
so do not use one family's
totalpopulationto weight the other's values —state_rollup.csvandnmf_state_rollup.csvare each weighted by their own.
What they do share is category and measure. The nine ACS measure IDs do
not collide with PLACES' forty, so one 49-row catalog spans all seven pages,
and data_source (BRFSS vs 5-year ACS) or category_id = 'SDOH'
separates them:
What health-charts plots¶
Every page of the portal is chartable at county level from committed CSVs, with no DoltHub call at all:
| Portal page | categoryid |
County file |
|---|---|---|
| Health Outcomes | HLTHOUT |
county_crude.csv / county_ageadj.csv |
| Prevention | PREVENT |
county_crude.csv / county_ageadj.csv |
| Health Risk Behaviors | RISKBEH |
county_crude.csv / county_ageadj.csv |
| Health Status | HLTHSTAT |
county_crude.csv / county_ageadj.csv |
| Disability | DISABLT |
county_crude.csv / county_ageadj.csv |
| Health-Related Social Needs | SOCLNEED |
county_crude.csv / county_ageadj.csv |
| Non-Medical Factors | SDOH |
nmf_county.csv |
Filter by measureid; join measures.csv on measureid for labels, units and
the category a measure belongs to. state_rollup.csv and
nmf_state_rollup.csv give population-weighted state means for the same
measures, for a state-level view without re-aggregating in the browser.
Below county — place, census tract, ZCTA — is Dolt-only, and that is where the query limits below start to matter.
The two tiers¶
Committed CSVs (data/processed/places/) serve anything that needs the
whole country at once:
| File | Rows | Size | Family |
|---|---|---|---|
county_crude.csv |
114,576 | 4.1 MB | PLACES |
county_ageadj.csv |
114,576 | 4.1 MB | PLACES |
state_rollup.csv |
3,814 | 122 KB | PLACES |
nmf_county.csv |
28,278 | 1.3 MB | Non-Medical |
nmf_state_rollup.csv |
459 | 10 KB | Non-Medical |
measures.csv |
49 | 5 KB | both |
nmf_county.csv adds a moe column — the ACS family reports a margin of
error rather than confidence limits — but its first six columns match
county_crude.csv exactly, so a chart that ignores the seventh reads both the
same way. Both county files drop the national aggregate row, which stays
queryable in Dolt.
county_crude.csv uses the same six columns as the older
data/processed/cdc_open/places_county.csv and is a strict superset of it — all
40 measures rather than 8, plus the age-adjusted companion.
Live SQL covers drill-down. The DoltHub API sends CORS headers and needs no key, so health-charts can query it straight from the browser:
Two limits shape every query, both measured against the live database:
- 1000 rows per response, reported as a
RowLimitstatus rather than an error, so every query needs an explicitLIMIT. - A query deadline of roughly 55 seconds, after which the API returns
context deadline exceeded. This is the one that bites: it is entirely possible to write a correct query against 6.6M rows that simply never returns.
Query shapes that work¶
The fact table's primary key is
(release_year, geo_level, location_id, measure_id, data_value_type, year).
Anything that hits a prefix of it is a fast range scan:
-- One county's full profile. 40 rows, ~0.3s.
SELECT measure_id, data_value, low_confidence_limit, high_confidence_limit
FROM measurement
WHERE release_year=2025 AND geo_level='county' AND location_id='01073'
AND data_value_type='CrdPrv';
-- One census tract. 40 rows, ~0.3s.
SELECT measure_id, data_value FROM measurement
WHERE release_year=2025 AND geo_level='tract' AND location_id='01073010900';
-- Every tract in a county, by PK prefix. 189 rows, ~0.1s.
SELECT location_id, location_name, lat, lon FROM location
WHERE geo_level='tract' AND location_id LIKE '01073%' LIMIT 1000;
nmf_measurement is keyed (period, geo_level, location_id, measure_id) and
behaves the same way:
-- One county's 9 non-medical factors. 0.4s.
SELECT measure_id, data_value, moe FROM nmf_measurement
WHERE period='2017-2021' AND geo_level='county' AND location_id='01073';
-- One census tract. 0.5s.
SELECT measure_id, data_value FROM nmf_measurement
WHERE period='2017-2021' AND geo_level='tract' AND location_id='01073010900';
-- Every county for one measure, via idx_nmf_measurement_slice. 1.0s.
SELECT location_id, data_value FROM nmf_measurement
WHERE period='2017-2021' AND geo_level='county' AND measure_id='POV150'
LIMIT 1000;
The dimension tables are small enough to fetch whole (~0.1s each), which is how labels should be resolved:
Two shapes to avoid¶
Never join against measurement. The obvious query — joining measure to
get a readable label — reliably exceeds the deadline even when the fact-table
side is a fast 40-row PK lookup:
-- TIMES OUT (54.5s measured), despite the WHERE matching a PK prefix.
SELECT m.short_question_text, f.data_value
FROM measurement f JOIN measure m USING (measure_id)
WHERE f.release_year=2025 AND f.geo_level='county' AND f.location_id='01073';
Fetch the 49-row measure table once and join client-side instead — or just
read the committed measures.csv, which is already there for exactly this.
This one is about size, not shape. The identical join against
nmf_measurement returns in 0.5s, because that table holds 1.34M rows
rather than 6.6M. So the honest rule is: joining a fact table is fine until it
is a few million rows, and there is no warning when you cross the line — the
query simply stops returning. Resolving labels client-side works at every size,
which is why it is the standing advice for both tables.
Do not combine geo_level with a secondary-index column. Mixing the
primary key's leading column with an indexed non-key column produces a bad
plan, on a table of only 149k rows:
-- TIMES OUT (>54s)
SELECT location_id FROM location WHERE geo_level='tract' AND county_fips='01073';
-- 0.3s, same 189 rows
SELECT location_id FROM location WHERE county_fips='01073' LIMIT 1000;
Both indexes exist and are correct (SHOW CREATE TABLE confirms it); this is
the query planner, not the schema. It reproduces exactly on nmf_location
(54.3s vs 0.8s for the same 189 rows), so treat it as a property of the API's
planner rather than of either table.
Schema¶
measurement is the fact table, keyed
(release_year, geo_level, location_id, measure_id, data_value_type, year).
location, location_population, measure and category are dimensions.
Eleven of the source's twenty-four columns repeat identically on every row.
Verified against the full exports: population is constant per
(location, year) — zero violations across all 229,298 county rows — and
geolocation is constant per location. Hoisting them into dimensions takes the
fact table from ~239 bytes/row to about 45.
Two asymmetries the schema has to carry: county-level exports have no
CountyName/CountyFIPS column, and age-adjusted prevalence exists only at
county and place level — tract and ZCTA are crude-only.
nmf_measurement is the second fact table, keyed
(period, geo_level, location_id, measure_id), with nmf_location and
nmf_location_population beside it and nmf_release_meta as its ledger. It
carries its own asymmetries: place has no county columns (PLACES place does),
and ZCTA has no state columns at all. Population is constant per location with
no year dimension — zero violations across all 28,287 county rows — since
the whole file is one ACS period.
Usage¶
uv run python -m places list # the registry, both families
uv run python -m places status # has CDC published newer data than the mirror has?
uv run python -m places sync --geo county # mirror one PLACES geography
uv run python -m places sync --all --family nmf # mirror Non-Medical Factors
uv run python -m places sync --all --family both # all seven portal pages
uv run python -m places derive # rebuild the committed CSVs
uv run python -m places query "select count(*) from measurement"
--family defaults to places — the six BRFSS categories. derive always
rebuilds both families' slices, since it reads the two county exports directly
from Socrata and they cost about ten seconds together.
sync needs DOLTHUB_TOKEN; reads need nothing.
Scheduling¶
update_places.yml runs monthly (1st, 17:00 UTC) rather than weekly, because
both products are annual at best — the four 2025 PLACES datasets were last
updated in December 2025, and the Non-Medical Factors datasets have not moved
since November 2023. sync compares Socrata's rowsUpdatedAt against the
matching ledger table before downloading, so a run against unchanged data costs
one metadata request per leg and transfers nothing.
The matrix is family × geo — eight legs — with max-parallel: 1,
because DoltHub runs one import job per database at a time.
How the import actually works¶
Worth knowing before changing dolt.py, because none of it is obvious:
- v1alpha1, not v2. The v2 REST API documents a nicer import (pre-signed
multipart, no size cap) but rejects tokens from
dolthub.com/settings/tokenswith 401no token found for given API token— verified with two independently generated tokens, scoped and unscoped, across/user,/databases/{o}/{d},/branchesand/imports/uploads. The same tokens authenticate v1alpha1 fine. - The
tokenscheme, notBearer.authorization: token <TOKEN>. fileNameis required in the uploadparams. Omitting it fails with an opaque upstream 400 that says nothing about the missing field.- An upload opens a pull request; it does not write to the branch. The job
reports success, a PR appears, and the table stays empty until that PR is
merged.
import_csvmerges it. - 100MB per upload. The tract fact table is ~137MB, so large files are split into chunks. Every import is an upsert on the primary key, so chunking changes nothing about the result.
- One import job per database at a time. Concurrent imports fail with
"cleaning up existing job, please wait a few minutes then try again", which is
why the workflow matrix sets
max-parallel: 1. release_year, notrelease. RELEASE is a reserved word in MySQL/Dolt and will not parse as a bare column name.
A county sync takes about three minutes, most of it waiting on server-side import and merge jobs rather than transferring data.
A note for anyone extending the fetch code¶
Use the bulk export endpoint (/api/views/{id}/rows.csv?accessType=DOWNLOAD),
not paged SoQL. One 50,000-row page of the tract dataset via $offset took
435 seconds; the entire 694 MB file via the export endpoint took 94.6.
And $where is silently ignored on the export endpoint — a filtered request
returned all 229,299 county rows, identical to unfiltered. It fails open, so
filtering happens client-side.