Working with ACS Data¶
The American Community Survey (ACS) is the most widely used Census Bureau product, providing detailed demographic, economic, housing, and social characteristics for every community in the United States. PyPUMS makes it straightforward to pull ACS summary tables directly from the Census API into pandas DataFrames.
Function signature¶
pypums.get_acs(
geography,
variables=None,
table=None,
state=None,
county=None,
year=2023,
survey="acs5",
output="tidy",
moe_level=90,
summary_var=None,
geometry=False,
keep_geo_vars=False,
cache_table=False,
key=None,
)
API key required
All Census API calls require a key. Request one for free at
https://api.census.gov/data/key_signup.html, then set the
CENSUS_API_KEY environment variable or pass key="YOUR_KEY" to
every call.
Getting started¶
Single variable¶
Retrieve total population (variable B01003_001) for every state:
GEOID NAME variable estimate moe 0 01 Alabama B01003_001 5028092 -555555555 1 02 Alaska B01003_001 734821 -555555555 2 04 Arizona B01003_001 7172282 -555555555 3 05 Arkansas B01003_001 3018669 -555555555 4 06 California B01003_001 39356104 -555555555
Why is moe showing NaN?
Some ACS variables — particularly total-population counts derived from the
Decennial Census base — do not have a margin of error. When the Census API
returns no MOE for a variable, PyPUMS fills the column with NaN. This is
expected, not a bug. Most other variables (income, housing, etc.) will
return numeric MOE values.
Variable naming convention
ACS variable IDs follow the pattern TABLE_SEQUENCE. For example,
B19013_001 is sequence 001 (the estimate) from table B19013
(Median Household Income). You do not need to add the E or M
suffix – PyPUMS appends those automatically.
Multiple variables¶
Pass a list to request several variables at once. Here we pull median
household income (B19013_001) and median home value (B25077_001) for
all counties in California:
GEOID NAME variable estimate moe 0 06001 Alameda County, California B19013_001 122488 1231 1 06003 Alpine County, California B19013_001 101125 17442 2 06005 Amador County, California B19013_001 74853 6048 3 06007 Butte County, California B19013_001 66085 2261 4 06009 Calaveras County, California B19013_001 77526 3875 5 06011 Colusa County, California B19013_001 69619 5745
Full table¶
Instead of listing individual variables, pass a table ID to pull the
entire table. Table B01001 is Sex by Age:
python exec="on" source="tabbed-left" session="acs"
age_sex = pypums.get_acs(
geography="state",
table="B01001",
state="CA",
year=2022,
)
print(age_sex.shape)
49 variables in B01001, one row per variable.
Large tables
Some tables have hundreds of variables. Pulling the full group
fetches every estimate and margin of error column, which can be
slow. Use cache_table=True to avoid redundant API calls during
iterative work.
Tidy vs. wide output¶
The output parameter controls the shape of the returned DataFrame.
Each row is one geography-variable combination. This is ideal for plotting with Altair.
df_tidy = pypums.get_acs(
geography="state",
variables=["B19013_001", "B25077_001"],
state="CA",
year=2022,
output="tidy",
)
| GEOID | NAME | variable | estimate | moe |
|---|---|---|---|---|
| 06001 | Alameda County, California | B19013_001 | 122488 | 1729 |
| 06001 | Alameda County, California | B25077_001 | 958200 | 11988 |
Columns: GEOID, NAME, variable, estimate, moe
Each variable gets its own estimate and MOE columns. This is closer to a traditional spreadsheet layout.
df_wide = pypums.get_acs(
geography="state",
variables=["B19013_001", "B25077_001"],
state="CA",
year=2022,
output="wide",
)
| GEOID | NAME | B19013_001E | B19013_001M | B25077_001E | B25077_001M |
|---|---|---|---|---|---|
| 06001 | Alameda County, California | 122488 | 1729 | 958200 | 11988 |
Columns: GEOID, NAME, then pairs of <VAR>E (estimate) and
<VAR>M (margin of error) for each variable.
Geography levels¶
PyPUMS supports all major Census geography levels. Some require a parent geography (state or county) as context.
| Geography | geography= |
Requires state? |
Requires county? |
|---|---|---|---|
| Nation | "us" |
||
| Region | "region" |
||
| Division | "division" |
||
| State | "state" |
||
| County | "county" |
Yes | |
| County subdivision | "county subdivision" |
Yes | Yes |
| Census tract | "tract" |
Yes | Yes |
| Block group | "block group" |
Yes | Yes |
| Place (city/town) | "place" |
Yes | |
| ZCTA (zip code) | "zcta" |
||
| PUMA | "puma" |
Yes | |
| CBSA (metro area) | "cbsa" |
||
| CSA | "csa" |
||
| Congressional district | "congressional district" |
Yes |
Sub-state queries¶
All counties in a state:
58 counties GEOID NAME variable estimate moe 0 06001 Alameda County, California B01003_001 1663823 -555555555 1 06003 Alpine County, California B01003_001 1515 206 2 06005 Amador County, California B01003_001 40577 -555555555
All tracts in a single county:
County FIPS codes
County FIPS codes are three-digit strings. Los Angeles County is
"037", Cook County (IL) is "031", Harris County (TX) is "201".
You can look up codes at
https://www.census.gov/library/reference/code-lists/ansi.html.
All block groups in a county:
All places in a state:
1862 places in Texas GEOID NAME variable estimate moe 0 4800100 Abbott city, Texas B01003_001 288 77 1 4800160 Abernathy city, Texas B01003_001 3266 812 2 4801000 Abilene city, Texas B01003_001 126356 280
ACS 1-Year vs. 5-Year¶
The survey parameter selects between the 1-year and 5-year ACS.
1-Year geography restrictions
The ACS 1-year survey does not publish data for tracts, block
groups, or places with fewer than 65,000 people. Use survey="acs5"
for small-area geographies.
Margin of error confidence levels¶
ACS estimates come with margins of error (MOE) at the 90% confidence level by default. You can rescale them to 95% or 99%:
# Default: 90% confidence
df_90 = pypums.get_acs(
geography="state",
variables=["B19013_001"],
year=2022,
moe_level=90,
)
# 95% confidence -- wider MOE
df_95 = pypums.get_acs(
geography="state",
variables=["B19013_001"],
year=2022,
moe_level=95,
)
# Compare California's MOE at different confidence levels
ca_90 = df_90[df_90["GEOID"] == "06"]["moe"].values[0]
ca_95 = df_95[df_95["GEOID"] == "06"]["moe"].values[0]
print(f"CA median income MOE at 90%: ±{ca_90:,.0f}")
print(f"CA median income MOE at 95%: ±{ca_95:,.0f}")
CA median income MOE at 90%: ±277 CA median income MOE at 95%: ±330
The rescaling uses the standard z-score formula:
| Level | Z-score | Scale factor (from 90%) |
|---|---|---|
| 90% | 1.645 | 1.000 |
| 95% | 1.960 | 1.192 |
| 99% | 2.576 | 1.566 |
When to change the confidence level
Most Census Bureau publications use the default 90% MOE. Switch to 95% or 99% when your analysis demands a higher confidence threshold, for instance when the result will inform policy decisions or statistical tests.
Summary variables¶
The summary_var parameter adds a denominator column alongside each
row in tidy output, making it easy to compute proportions:
```python exec=”on” source=”tabbed-left” session=”acs”
Sex by Age, with total population as the summary variable¶
age_with_total = pypums.get_acs( geography=”county”, table=”B01001”, state=”CA”, year=2022, summary_var=”B01003_001”, ) print(age_with_total.columns.tolist()) ```
The summary_est and summary_moe columns carry the denominator value for every row. You can then compute a share column directly:
Geometry support¶
Set geometry=True to return a GeoDataFrame with cartographic boundary
shapes joined to your data (downloaded via pygris, cached locally).
import altair as alt
alt.Chart(ca_counties_geo).mark_geoshape(stroke="white", strokeWidth=0.5).encode(
color=alt.Color("estimate:Q", legend=alt.Legend(title="Population")),
tooltip=["NAME:N", alt.Tooltip("estimate:Q", format=",")],
).project("albersUsa").properties(width=500, height=400)
Optional dependency
geometry=True requires geopandas. Install it with
uv add "pypums[spatial]".
Preserving raw FIPS columns¶
By default, PyPUMS collapses the individual FIPS components (state,
county, tract, etc.) into a single GEOID column and drops the raw
parts. Set keep_geo_vars=True to retain them:
This is useful when you need to join against other data sources that store FIPS codes in separate columns.
Caching¶
Set cache_table=True to cache the API response on disk for 24 hours:
df = pypums.get_acs(
geography="tract",
variables=["B19013_001"],
state="CA",
county="037",
year=2022,
cache_table=True,
)
The cache is stored under ~/.pypums/cache/api/. Subsequent calls with
the same parameters return instantly from disk instead of hitting the
Census API.
When to cache
Enable caching when you are iterating on analysis code and making the same API call repeatedly. Disable it (the default) for one-off pulls or production pipelines where you always want fresh data.
Common patterns¶
Median household income for all states¶
NAME estimate moe
8 District of Columbia 101722 1569 20 Maryland 98461 511 30 New Jersey 97126 605 21 Massachusetts 96505 551 11 Hawaii 94814 994 4 California 91905 277 29 New Hampshire 90845 966 47 Washington 90325 433 6 Connecticut 90213 730 5 Colorado 87598 508
Poverty rate by county¶
Table B17001 contains poverty status counts. Variable B17001_002 is
the count below the poverty level, and B17001_001 is the universe
(total for whom poverty status is determined):
poverty = pypums.get_acs(
geography="county",
variables=["B17001_002"],
state="NY",
year=2022,
summary_var="B17001_001",
)
poverty["poverty_rate"] = poverty["estimate"] / poverty["summary_est"]
poverty_sorted = poverty.sort_values("poverty_rate", ascending=False)
print(poverty_sorted.head(5)[["NAME", "estimate", "summary_est", "poverty_rate"]])
NAME estimate summary_est poverty_rate
2 Bronx County, New York 379925 1411574 0.269150 3 Broome County, New York 35991 188620 0.190812 23 Kings County, New York 503524 2653797 0.189737 16 Franklin County, New York 7719 43182 0.178755 6 Chautauqua County, New York 21513 122629 0.175432
Race/ethnicity composition for a metro area¶
GEOID NAME variable estimate moe 0 10100 Aberdeen, SD Micro Area B03002_003 36516 68 1 10140 Aberdeen, WA Micro Area B03002_003 58701 259 2 10180 Abilene, TX Metro Area B03002_003 110534 543 3 10220 Ada, OK Micro Area B03002_003 24298 43 4 10300 Adrian, MI Micro Area B03002_003 84864 211 5 10380 Aguadilla-Isabela, PR Metro Area B03002_003 6115 819 6 10420 Akron, OH Metro Area B03002_003 545149 1345 7 10460 Alamogordo, NM Micro Area B03002_003 31813 317
Educational attainment for tracts with geometry¶
education = pypums.get_acs(
geography="tract",
variables=[
"B15003_022", # Bachelor's degree
"B15003_023", # Master's degree
"B15003_025", # Doctorate degree
],
state="MA",
county="025", # Suffolk County (Boston)
year=2022,
summary_var="B15003_001",
geometry=True,
)
education["pct_bachelors_plus"] = (
education["estimate"] / education["summary_est"]
)
print(education[["NAME", "variable", "estimate", "summary_est", "pct_bachelors_plus"]].head(3))
NAME ... pct_bachelors_plus
0 Census Tract 1.01; Suffolk County; Massachusetts … 0.363167 1 Census Tract 1.02; Suffolk County; Massachusetts … 0.453608 2 Census Tract 2.01; Suffolk County; Massachusetts … 0.344316
[3 rows x 5 columns]
Year-over-year comparison¶
import pandas as pd
years = [2019, 2021, 2022, 2023]
frames = []
for yr in years:
df = pypums.get_acs(
geography="state",
variables=["B19013_001"],
year=yr,
survey="acs1",
cache_table=True,
)
df["year"] = yr
frames.append(df)
trend = pd.concat(frames, ignore_index=True)
print(trend[trend["GEOID"] == "06"][["NAME", "estimate", "year"]])
NAME estimate year
48 California 80440 2019 56 California 84907 2021 108 California 91551 2022 160 California 95521 2023
2020 ACS 1-Year
The Census Bureau did not release a standard 2020 ACS 1-Year product due to low response rates during the COVID-19 pandemic. An experimental release was published separately. Use the 5-year ACS for 2020 small-area analysis.
Combining MOE utilities with ACS data¶
After retrieving data, use PyPUMS MOE functions to compute derived margins of error:
from pypums import moe_sum, moe_prop, significance
# Sum two estimates and their MOEs
combined_moe = moe_sum([1500, 2300]) # MOEs from two variables
# Proportion MOE
prop_moe = moe_prop(
num=5000,
denom=25000,
moe_num=800,
moe_denom=1200,
)
# Test whether two estimates differ significantly
is_sig = significance(
est1=55000,
est2=62000,
moe1=3200,
moe2=2800,
clevel=0.90,
)
print(f"Combined MOE: {combined_moe:.1f}")
print(f"Proportion MOE: {prop_moe:.4f}")
print(f"Statistically significant: {is_sig}")
Combined MOE: 2745.9 Proportion MOE: 0.0305 Statistically significant: True
Discovering variables¶
Use load_variables() to search for variable codes in any ACS dataset:
vars_2022 = pypums.load_variables(2022, "acs5", cache=True)
# Filter to a concept
income_vars = vars_2022[
vars_2022["concept"].str.contains("MEDIAN HOUSEHOLD INCOME", na=False)
]
print(income_vars[["name", "label"]].head())
name label
0 B19013_001 Estimate!!Median household income in the ...
1 B19013A_001 Estimate!!Median household income in the ...
2 B19013B_001 Estimate!!Median household income in the ...
3 B19013C_001 Estimate!!Median household income in the ...
4 B19013D_001 Estimate!!Median household income in the ...
See the Finding Variables guide for full details.
Troubleshooting¶
ValueError: No Census API key found
: You need to set the CENSUS_API_KEY environment variable or call
census_api_key() before making any API requests. Request a free key at
https://api.census.gov/data/key_signup.html.
ValueError: Geography 'tract' requires a state
: Sub-state geographies (tract, block group, place, county, etc.) need the
state parameter. Pass a state abbreviation, full name, or FIPS code.
ValueError: Must provide either 'variables' or 'table'
: You must pass one of the two parameters. Use variables for specific
variable codes or table for an entire table group.
Empty DataFrame returned
: The variable code may be wrong for the requested year or survey. Use
load_variables() to check which variables are available for a given
year and dataset.
ImportError: geopandas
: Geometry support requires the spatial extras. Install them with
uv add "pypums[spatial]".
moe_level must be one of [90, 95, 99]
: Only these three confidence levels are supported for margin-of-error
rescaling. Pass moe_level=90, moe_level=95, or moe_level=99.
See Also¶
- API Reference — Full function signature for
get_acs()and related functions - Finding Variables — Discovering variable codes with
load_variables() - Geography & FIPS — Understanding geography levels and FIPS code lookups
- Margins of Error — MOE propagation formulas and statistical testing
- Spatial Data — Attaching geometry to query results