Skip to content

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:

import pypums

pop = pypums.get_acs(
    geography="state",
    variables=["B01003_001"],
    year=2022,
)
print(pop.head())

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:

income_housing = pypums.get_acs(
    geography="county",
    variables=["B19013_001", "B25077_001"],
    state="CA",
    year=2022,
)
print(income_housing.head(6))

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:

ca_counties = pypums.get_acs(
    geography="county",
    variables=["B01003_001"],
    state="CA",
    year=2022,
)
print(f"{len(ca_counties)} counties")
print(ca_counties.head(3))

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:

la_tracts = pypums.get_acs(
    geography="tract",
    variables=["B01003_001"],
    state="CA",
    county="037",
    year=2022,
)
print(f"{len(la_tracts)} tracts in LA County")

2498 tracts in LA 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:

cook_bgs = pypums.get_acs(
    geography="block group",
    variables=["B19013_001"],
    state="IL",
    county="031",
    year=2022,
)
print(f"{len(cook_bgs)} block groups in Cook County")

4002 block groups in Cook County

All places in a state:

tx_places = pypums.get_acs(
    geography="place",
    variables=["B01003_001"],
    state="TX",
    year=2022,
)
print(f"{len(tx_places)} places in Texas")
print(tx_places.head(3))

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.

df = pypums.get_acs(
    geography="tract",
    variables=["B19013_001"],
    state="CA",
    county="037",
    year=2022,
    survey="acs5",   # default
)
  • Covers all geographies, including tracts and block groups
  • 5-year pooled sample provides smaller margins of error
  • Best for small-area analysis
df = pypums.get_acs(
    geography="county",
    variables=["B19013_001"],
    state="CA",
    year=2022,
    survey="acs1",
)
  • Only areas with population 65,000+
  • Most current single-year snapshot
  • Best for year-over-year trend analysis of large areas

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:

age_with_total["share"] = (
    age_with_total["estimate"] / age_with_total["summary_est"]
)

Geometry support

Set geometry=True to return a GeoDataFrame with cartographic boundary shapes joined to your data (downloaded via pygris, cached locally).

ca_counties_geo = pypums.get_acs(
    geography="county",
    variables=["B01003_001"],
    state="CA",
    year=2022,
    geometry=True,
)
print(type(ca_counties_geo))
print(ca_counties_geo.head(3))

GEOID … moe 0 06001 … -555555555 1 06003 … 206 2 06005 … -555555555

[3 rows x 6 columns]

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:

tracts = pypums.get_acs(
    geography="tract",
    variables=["B01003_001"],
    state="CA",
    county="037",
    year=2022,
    keep_geo_vars=True,
)
print(tracts.columns.tolist())

[‘GEOID’, ‘NAME’, ‘state’, ‘county’, ‘tract’, ‘variable’, ‘estimate’, ‘moe’]

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

income = pypums.get_acs(
    geography="state",
    variables=["B19013_001"],
    year=2022,
)
top_10 = income.nlargest(10, "estimate")
print(top_10[["NAME", "estimate", "moe"]])
                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

race = pypums.get_acs(
    geography="cbsa",
    variables=[
        "B03002_003",  # White alone, not Hispanic
        "B03002_004",  # Black alone
        "B03002_006",  # Asian alone
        "B03002_012",  # Hispanic or Latino
    ],
    year=2022,
)
print(race.head(8))

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