Skip to content

US Census Integration

Joel Natividad edited this page Oct 14, 2026 · 5 revisions

US Census Integration

Tier: Intermediate Commands covered: viz choropleth, viz smart, geocode

Important

This integration only applies to datasets about US places — the 50 states, DC, Puerto Rico and the island areas (AS, GU, MP, VI). It fetches boundaries and population from US Census Bureau services, so a column of Canadian provinces, UK postcodes or German districts will not resolve. For non-US regions, supply your own boundary file with --geojson <file> (or bin lat/lon points into it with point-in-polygon), or use --location-mode iso3 for country-level maps.

qsv can turn a CSV with nothing but US place codes or names into a county, ZIP-area, tract or state map — boundaries fetched for you — and into a per-capita rate map with population fetched for you too. This page is the orientation layer: just enough Census background to use it well, worked examples, and the practices that avoid the common wrong turns. Flag-level reference stays in docs/help/viz.md and docs/help/geocode.md; the in-depth behavior is in Visualization → US boundaries and Visualization → Rate maps.

The Census in five minutes

You don't need to be a demographer — you need five concepts.

1. Geographies are identified by GEOIDs, which are built from FIPS codes. A GEOID is a fixed-width string of digits, and each level prefixes the one above it:

Geography GEOID width Example Built from
State 2 42 (Pennsylvania) state FIPS
County 5 42003 (Allegheny County, PA) state 42 + county 003
Census tract 11 42003020100 county 42003 + tract 020100
Place (city, town, CDP) 7 4261000 (Pittsburgh) state 42 + place 61000
ZCTA (≈ ZIP code area) 5 15213 the ZIP code itself

Two consequences matter constantly:

  • They are strings, not numbers. 01001 (Autauga County, AL) is not 1001. Spreadsheets and numeric CSV inference strip the zero.
  • A "county FIPS code" can mean two things. The bare 3-digit county part (003) is only unique within a state; the 5-digit GEOID (42003) is unique nationally. Census tools want the 5-digit form.

2. A ZCTA is not a ZIP code. ZIP codes are USPS delivery routes, not areas; ZCTAs ("ZIP Code Tabulation Areas") are the Census's polygon approximation of them. Most residential ZIPs have a ZCTA with the same number. PO-box-only and single-building ZIPs do not, so those rows have nowhere to go on a map.

3. A "place" is a city/town or a Census Designated Place (CDP). CDPs are unincorporated communities the Census delineates for statistics (many suburbs are CDPs). qsv's place layer includes both.

4. Shapes and numbers come from two different services. TIGERweb serves boundary polygons (the shapes); the Census Data API serves statistics such as population (the numbers). qsv uses the American Community Survey (ACS) 5-year estimates for population — table B01003, total population — which cover every county in the 50 states, DC and Puerto Rico. The island areas (AS, GU, MP, VI) are not in the ACS, so they get boundaries but no population.

5. Geography changes over time, so everything has a vintage. Counties are occasionally created, merged or renamed. The largest recent change: in 2022 Connecticut replaced its 8 counties with 9 "planning regions" for statistical purposes, with entirely new GEOIDs (Litchfield County exists in ACS 2021, Northwest Hills Planning Region in ACS 2023, and they share no code). A vintage is the year a set of boundaries or estimates describes; mixing vintages is how codes silently stop matching.

What qsv does with it

You have qsv feature Census service Network sends
City names or lat/lon geocode with us_state_fips_code / us_county_fips_code none (local Geonames index) nothing
A column of state/county/ZCTA/tract/place codes or names viz choropleth / viz smart with --geojson auto TIGERweb the distinct region codes (or the states they fall in)
…and you want a rate, not a count --denominator census Census Data API the state/county codes + your API key

Your row data never leaves your machine; only region identifiers are sent to census.gov. Responses are cached under ~/.qsv-cache (viz-boundaries/, viz-denominators/) for 30 days, so a repeated command makes no network request at all.

Builds: the prebuilt qsv and qsvpy binaries (and any -F all_features build) include all three. qsvlite and qsvdp have no viz; qsvdp keeps geocode, qsvlite has neither. See Binary Variants.

Setup

  1. Boundaries need no setup. --geojson auto works out of the box.

  2. Population needs a free API key. Request one and export it:

    export QSV_CENSUS_API_KEY=your_key_here   # in your shell profile or CI secret store

    The key is only consulted on a cold denominator cache. Keep it in the environment or a secret manager — never inline it in a committed script.

  3. Optional cache tuning (Environment Variables):

    • QSV_VIZ_BOUNDARY_CACHE_TTL_DAYS — boundary freshness (default 30)
    • QSV_VIZ_DENOMINATOR_CACHE_TTL_DAYS — population freshness (default 30)

    For CI or air-gapped runs, run each command once online to warm ~/.qsv-cache, then carry the cache along.

Examples

All of these run against datasets committed in examples/viz/ — cd there first. Each prints a one-line provenance summary on stderr, quoted below; the TIGERweb 2026 shown is the default (newest) vintage when this page was written, so yours may be newer.

1. County FIPS codes → county map, no boundary file

tristate_county_broadband.csv has a 5-digit county_fips column and a percentage measure:

qsv viz choropleth tristate_county_broadband.csv --locations county_fips \
    --value broadband_pct --agg mean --geojson auto -o broadband.html
--geojson auto: Census TIGERweb 2026, Counties (3 states) — boundaries cached at ~/.qsv-cache/viz-boundaries/….geojson

qsv reads the state prefix off each code, fetches only those three states' counties, and keys the map on properties.GEOID for you. --agg mean because a percentage is not additive.

2. County names → map, then per-capita

wpa_211_requests.csv is 279,464 real 2-1-1 helpline requests, one row each, with a county column of names (Allegheny County) and a state column (PA):

# raw count of requests per county
qsv viz choropleth wpa_211_requests.csv --locations county --region-state state \
    --geojson auto -o requests.html

# the same, divided by ACS population: requests per capita
qsv viz choropleth wpa_211_requests.csv --locations county --region-state state \
    --geojson auto --denominator census@2024 -o requests_per_capita.html
--geojson auto: Census TIGERweb 2026, Counties (1 state), region codes resolved from --locations county names — …

The names are resolved to GEOIDs against the Census's own name table, so Allegheny County and allegheny county both work. The rate map's footer states its sources — ACS 2020–2024 5-yr (B01003). Boundaries: Census TIGERweb 2026, Counties (1 state)… — so a published figure carries its own citation. Count and rate tell different stories here: Allegheny leads both, but Venango climbs from 11th to 3rd per capita. See the rendered wpa_211_requests Data Schematic.

3. State codes → state map

us_state_stats.csv holds USPS codes (PA, AK, …). State FIPS (42) and full names (Pennsylvania) work too. (New in 24.0.0.)

qsv viz choropleth us_state_stats.csv --locations state \
    --value renewable_electricity_pct --agg mean --geojson census:state -o states.html

4. Enrich lat/lon with county GEOIDs, then map

us_cities.csv has lat/lon columns but no codes. geocode reverse wants a single "lat, lon" column, and a template that concatenates state and county FIPS gives the 5-digit GEOID in one step:

qsv apply dynfmt --formatstr '{lat}, {lon}' --new-column latlon us_cities.csv -o cities_ll.csv

qsv geocode reverse latlon --country US -c county_geoid \
    -f '{us_state_fips_code}{us_county_fips_code}' cities_ll.csv -o cities_geoid.csv

qsv viz choropleth cities_geoid.csv --locations county_geoid \
    --value population_m --agg sum --geojson census:county -o cities_by_county.html
--geojson census:county: 2 of 267 distinct --locations values matched no boundary and are omitted from the map (e.g. 36, 11).

Warning

Fixed in 25.0.0 (#4771, #4772): in 24.0.0 and earlier, us_county_fips_code dropped the first digit of any county code ≥ 100. Baltimore city (510) came out 010, and Montgomery County AL (101) came out 001 (which is Autauga County), usually naming a different real county with no warning. With those versions, 88 of the 330 rows in us_cities.csv were wrong, and the omitted-values line showed only the 15 codes that happened to name no county. With a fixed build, only 36 and 11 are left out (see below). Regenerate any file enriched with us_county_fips_code by 24.0.0 or earlier.

Separately, Washington DC and points that resolve to Manhattan's "New York" place have no county in Geonames, so the concatenated template yields just the state code (11, 36).

Reverse geocoding is also an approximation: it returns the county of the nearest populated place, not the county that contains the point, so a rural point just across a county line from a town lands in the town's county. When precision matters, fetch the county boundaries once as a file and use exact point-in-polygon binning instead: --lat lat --lon lon --geojson counties.geojson --feature-id-key properties.GEOID. (--geojson auto itself needs a --locations code column, so it cannot bin raw points.)

5. City names → county map

Place names are ambiguous — there are dozens of Springfields — so give the geocoder each row's state. A small inline example:

cat > cities.csv <<'EOF'
city,state,requests
Springfield,IL,120
Springfield,MA,95
Springfield,MO,80
Columbus,OH,210
Columbus,GA,60
Portland,OR,175
Portland,ME,40
EOF

qsv viz choropleth cities.csv --locations city --geocode --geocode-admin1 state \
    --value requests --agg sum --geojson auto -o by_county.html
--geojson auto: Census TIGERweb 2026, Counties (7 states), region codes forward-geocoded from --locations place names — …

7 states — every row placed correctly. Drop --geocode-admin1 state and the same command succeeds with 3 states: each pair of same-named cities collapses onto one of them, with no warning. That silent collapse is why --geocode-admin1 belongs on every run over city names.

6. A full dashboard with a census rate panel

viz smart builds a whole Data Schematic; a dictionary tells it which column is the region code (county_fips, tagged geo.county_fips), and --denominator census adds a per-capita panel beside the count map:

qsv viz smart tristate_county_incidents.csv --dict-info \
    --dictionary tristate_county_incidents_dict.schema.json \
    --geojson auto --denominator census@2024 -o incidents_dashboard.html

This dataset is synthetic and built so that the rate inverts the count — the most populous counties have the most incidents but the lowest rate. That is the general lesson of rate maps: a count choropleth is mostly a population map.

7. Pin vintages for a reproducible figure

The default is the newest boundaries and the newest ACS release, so a figure regenerated next year can shift. For anything you publish, pin both — to the same year:

qsv viz choropleth wpa_211_requests.csv --locations county --region-state state \
    --geojson census:county@2023 --denominator census@2023 -o requests_2023.html

The footer then reads ACS 2019–2023 5-yr (B01003). Boundaries: Census TIGERweb 2023, ….

Best practices

Codes

  • Keep geographic codes as text end to end. qsv re-pads codes that lost a leading zero (1001 → 01001, Puerto Rico's ZCTA 601 → 00601), but your joins, spreadsheets and other tools will not. Never round-trip a FIPS column through Excel.
  • Use the 5-digit county GEOID, never the bare 3-digit county code. geocode's us_county_fips_code is 3 digits (003) — unique only within a state. Concatenate it with us_state_fips_code, as in example 4 (on 24.0.0 or earlier, see the #4771 note there first). A 3-digit column fails loudly (no US state could be derived from the region-code column / match no Census geography); it won't draw a wrong map.
  • Prefer codes over names. Codes are unambiguous and exact; names need disambiguation and spelling discipline. If you have names, convert once and keep the GEOID column.

Names

  • County names need a state. 422 county names are shared — Washington County exists in 30 states — so a bare national column is refused, not guessed. Pass --region-state <column> (viz choropleth) or tag the state column geo.state in the dictionary (viz smart).
  • Never use --geocode on county names. It searches city names, and every wrong answer is a real county (Litchfield County → Maricopa County, AZ), so coverage reads 100%. qsv refuses this combination; county names go through the Census name table with no --geocode.
  • City names need --geocode-admin1, as example 5 shows.

Geography and vintage

  • Name the layer when you know it. 5-digit codes can be counties or ZCTAs. qsv probes both and refuses if both match (county (3/3, use --geojson census:county) or ZCTA (3/3, use --geojson census:zcta)); --geojson census:county (or :zcta, :tract, :place, :state) removes the guesswork.
  • Remember ZIP ≠ ZCTA. Expect PO-box and single-building ZIPs to drop out of a ZCTA map, and ZCTA and tract maps get boundaries but no --denominator census — it covers states and counties only. For those, use --denominator-key on a boundary file that carries population, or a population column.
  • Pin vintages for published work, and pin both to the same year (example 7). Connecticut data from before 2022 uses county codes; from 2022 on it uses planning regions.

Analysis

  • Map a rate, not a count, whenever regions differ in population — otherwise you mostly map where people live. And read the rate honestly: a 2-1-1 or 311 rate measures how much a service is used as much as how much it is needed.
  • Use --agg mean (or median) for intensive measures — percentages, densities, prices. Summing them is meaningless, and a denominator is rejected for them anyway.

Operations

  • Read stderr. The provenance line, the omitted-values count and any "excluded from the rate" list are how qsv tells you what the map does not show. A refusal (exit 1/2) is a safeguard against a misleading map, not a bug to route around.
  • Omit --feature-id-key with --geojson auto — it is set for you.
  • Warm the cache before CI or demos, and keep QSV_CENSUS_API_KEY in a secret store.

Troubleshooting

Symptom Likely cause Fix
no US state could be derived from the region-code column 3-digit county codes, or a non-code column Build 5-digit GEOIDs (state + county FIPS)
match no Census geography in the … vintage Codes are not US codes, or are the wrong width Check a few values; for non-US data supply --geojson <file>
match more than one Census geography equally well 5-digit codes valid as both counties and ZCTAs Use --geojson census:county or census:zcta
… county names are carried by more than one county Bare county names without a state Add --region-state <column>
look like COUNTY names … which --geocode cannot resolve --geocode on a county-name column Drop --geocode
N of M distinct --locations values matched no boundary Partial coverage — DC/Manhattan with no county, geocode county codes ≥ 100 from 24.0.0 or earlier (#4771), PO-box ZIPs, a vintage mismatch Inspect the listed codes; pin a matching vintage or fix the source codes
the Census Data API requires an API key QSV_CENSUS_API_KEY unset and the cache is cold Set the key (free)
--denominator census resolves US state and county population, but these regions are zcta Per-capita requested on ZCTAs/tracts Use --denominator-key or a population column
Territory or military codes listed as excluded from the rate ACS doesn't cover them Expected; they're reported, not dropped silently
A choropleth with a custom boundary file shades nothing Wrong --feature-id-key Run --check-geojson-key

Beyond qsv: richer Census data via MCP

qsv fetches exactly two things from the Census — boundaries and total population. For anything else (income, age, housing, commuting…), pair the qsv MCP Server with the US Census MCP Server in an AI agent: Census for the statistics, qsv for profiling, cleaning and joining on GEOIDs. Worked examples:

The best practices above apply unchanged: join on 5-digit text GEOIDs and match vintages.

See also

Clone this wiki locally