Exploring U.S. Census Data Across Seven MSAs with the IPUMS NHGIS API

Introduction and objectives

Census data provide a fundamental source of information on the demographic, social, and economic characteristics of the U.S. population. This notebook provides a brief, reproducible guide to accessing and exploring census data at the Census Block Group (CBG) level using the IPUMS NHGIS API.

The notebook focuses on seven U.S. MSAs: Los Angeles–Long Beach–Anaheim (LA), Houston–Pasadena–The Woodlands (Houston), Atlanta–Sandy Springs–Roswell (Atlanta), Miami–Fort Lauderdale–West Palm Beach (Miami), Seattle–Tacoma–Bellevue (Seattle), Denver–Aurora–Centennial (Denver), and Minneapolis–Saint Paul (Twin Cities). These seven MSAs are the examples presented in the Visitor Census paper.

The workflow has three main objectives:

  1. Extract 2020 CBG boundaries from U.S. Census Bureau TIGER/Line shapefiles.
  2. Extract 2021 ACS 5-year total population and median household income using the IPUMS NHGIS API.
  3. Link the attributes to CBG boundaries and explore their statistical and spatial distributions.

Computational set-up

Required packages

The workflow uses requests for API access, pandas for tabular data, geopandas for spatial data, and matplotlib, seaborn for visualization. folium and mapclassify support interactive and classified maps.

import sys, subprocess, importlib.util, requests, io, zipfile, time
import pandas as pd
import numpy as np

for pkg in ["geopandas", "matplotlib", "folium", "mapclassify"]:
    if importlib.util.find_spec(pkg) is None:
        subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])

import geopandas as gpd
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from matplotlib.ticker import FuncFormatter
import seaborn as sns
import warnings
warnings.filterwarnings("ignore")

IPUMS NHGIS API set-up

Follow the IPUMS API instructions at https://developer.ipums.org/docs/v2/get-started/ to register for an API key. Replace YOUR_IPUMS_API_KEY below with your own key before running the API cells.

API_KEY = "YOUR_IPUMS_API_KEY"
headers = {"Authorization": API_KEY}

url = "https://api.ipums.org/metadata/datasets"
params = {"collection": "nhgis", "version": 2}
response = requests.get(url, params=params, headers=headers)
response.raise_for_status()
data = response.json()

Extract census boundaries from the U.S. Census Bureau TIGER/Line file download service

CBG boundaries for seven target MSAs

We consider the following parameters when building the extraction function:

The data extraction process may take a few minutes, depending on computing resources.

def get_msa_cbg(state_fips, county_fips, year=2020):
    url = f"https://www2.census.gov/geo/tiger/TIGER{year}/BG/tl_{year}_{state_fips}_bg.zip"
    gdf = gpd.read_file(url)
    return gdf[gdf["COUNTYFP"].isin(county_fips)].copy()

TCMA_county_fips = ["003", "019", "037", "053", "123", "139", "163"]
LA_county_fips = ["037", "059"]
Houston_county_fips = ["015", "039", "071", "157", "167", "201", "291", "339", "407", "473"]
Seattle_county_fips = ["033", "053", "061"]
Miami_county_fips = ["011", "086", "099"]
Atlanta_county_fips = ["013", "015", "035", "045", "057", "063", "067", "077", "085", "089", "097", "113", "117", "121", "135", "143", "149", "151", "159", "187", "199", "211", "217", "223", "227", "231", "247", "255", "297"]
Denver_county_fips = ["001", "005", "014", "019", "031", "035", "039", "047", "059", "093"]

TCMA_cbg = get_msa_cbg("27", TCMA_county_fips)
LA_cbg = get_msa_cbg("06", LA_county_fips)
Houston_cbg = get_msa_cbg("48", Houston_county_fips)
Seattle_cbg = get_msa_cbg("53", Seattle_county_fips)
Miami_cbg = get_msa_cbg("12", Miami_county_fips)
Atlanta_cbg = get_msa_cbg("13", Atlanta_county_fips)
Denver_cbg = get_msa_cbg("08", Denver_county_fips)

Some statistics and visualizations

Combine CBG boundaries for seven MSAs into a single GeoDataFrame and visualize their spatial extents interactively.

msa_dict = {
    "TCMA": TCMA_cbg,
    "Los Angeles": LA_cbg,
    "Houston": Houston_cbg,
    "Seattle": Seattle_cbg,
    "Miami": Miami_cbg,
    "Atlanta": Atlanta_cbg,
    "Denver": Denver_cbg
}

all_msa_cbg = gpd.GeoDataFrame(
    pd.concat(
        [gdf.to_crs(TCMA_cbg.crs).assign(MSA=name) for name, gdf in msa_dict.items()],
        ignore_index=True
    ),
    geometry="geometry",
    crs=TCMA_cbg.crs
)

all_msa_cbg.explore(
    column="MSA",
    categorical=True,
    tooltip=["MSA", "GEOID"],
    #tiles="CartoDB positron"
)

Report the number of CBGs and map their boundaries for the seven MSAs.

def summarize_msa_cbg(gdf, name="MSA", plot=True):
    print(f"{name}: {len(gdf):,} CBGs")
    print(f"Counties: {gdf['COUNTYFP'].nunique()}")
    if plot:
        ax = gdf.plot(figsize=(8, 8), color="lightblue", edgecolor="black", linewidth=0.2)
        ax.set_title(f"{name} Census Block Groups (n={len(gdf):,})")
        ax.axis("off")
    #return gdf[["STATEFP", "COUNTYFP", "GEOID"]]
summarize_msa_cbg(TCMA_cbg, "Twin Cities")
summarize_msa_cbg(LA_cbg, "Los Angeles")
summarize_msa_cbg(Houston_cbg, "Houston")
summarize_msa_cbg(Seattle_cbg, "Seattle")
summarize_msa_cbg(Miami_cbg, "Miami")
summarize_msa_cbg(Atlanta_cbg, "Atlanta")
summarize_msa_cbg(Denver_cbg, "Denver")
Twin Cities: 2,568 CBGs
Counties: 7
Los Angeles: 8,640 CBGs
Counties: 2
Houston: 4,176 CBGs
Counties: 10
Seattle: 2,738 CBGs
Counties: 3
Miami: 3,946 CBGs
Counties: 3
Atlanta: 3,950 CBGs
Counties: 29
Denver: 2,048 CBGs
Counties: 10

Extract 2021 ACS CBG Attributes from IPUMS NHGIS API

Data extraction

The extraction process includes four main steps:

  1. Identify the NHGIS dataset and tables for the 2021 ACS 5-year estimates, including total population and median household income.
  2. Submit an NHGIS extract request using the IPUMS API key, specifying block groups as the geographic level and the states covering the seven MSAs.
  3. Check the extract status and download the completed data from IPUMS.
  4. Filter the downloaded CBG records by state and county FIPS codes to obtain the ACS attributes for each MSA.
  1. Identify the NHGIS dataset and tables for the 2021 ACS 5-year estimates, including total population and median household income.
headers = {"Authorization": API_KEY}
dataset = "2017_2021_ACS5a" # 2021 ACS 5-year estimates

# Check table metadata
meta_url = f"https://api.ipums.org/metadata/datasets/{dataset}"
meta = requests.get(meta_url, headers=headers, params={"collection": "nhgis", "version": 2}).json()

tables = pd.DataFrame(meta["dataTables"])
tables[tables["description"].str.contains("Total Population|Median Household Income", case=False, na=False)]
name description universe nhgisCode sequence datasetName nVariables
11 B01003 Total Population Total population AON4 12 2017_2021_ACS5a 1
110 B19013 Median Household Income in the Past 12 Months ... Households AOQI 111 2017_2021_ACS5a 1
122 B19049 Median Household Income in the Past 12 Months ... Households AOQU 123 2017_2021_ACS5a 5
215 B25008 Total Population in Occupied Housing Units by ... Total population in occupied housing units AOS2 216 2017_2021_ACS5a 3
229 B25033 Total Population in Occupied Housing Units by ... Total population in occupied housing units AOTG 230 2017_2021_ACS5a 13
309 B29004 Median Household Income for Households with a ... Households with a citizen, voting-age householder AOVO 310 2017_2021_ACS5a 1

Explanation of the returned fields (IPUMS NHGIS Metadata API Documentation):

  1. Submit an NHGIS extract request using the IPUMS API key, specifying block groups as the geographic level and the states covering the seven MSAs.

Key variables are listed below. See the official ACS Table IDs page for more information.

extract = {
    "datasets": {
        dataset: {
            "dataTables": ["B01003", "B19013"], # "B01003" for total population, and "B19013" for median household income
            "geogLevels": ["blck_grp"] # Census block group (CBG) as the geographic level
        }
    },
    "geographicExtents": ["080", "120", "130", "270", "480", "530", "060"],  # the extent
    "dataFormat": "csv_no_header",
    "breakdownAndDataTypeLayout": "single_file",
    "description": "2021 ACS CBG population and household income"
}

url = "https://api.ipums.org/extracts/"
r = requests.post(url, headers={**headers, "Content-Type": "application/json"}, params={"collection": "nhgis", "version": 2}, json=extract)
result = r.json()
extract_number = result["number"]

print("Extract number:", extract_number)
print("Status:", result["status"])
Extract number: 78
Status: queued
  1. Check the extract status and download the completed data from IPUMS.
status_url = f"https://api.ipums.org/extracts/{extract_number}"

while True:
    status = requests.get(status_url, headers=headers, params={"collection": "nhgis", "version": 2}).json()
    if status["status"] == "completed":
        break
    print(status["status"])
    time.sleep(5)

download_url = status["downloadLinks"]["tableData"]["url"]
zip_data = requests.get(download_url, headers=headers).content

with zipfile.ZipFile(io.BytesIO(zip_data)) as z:
    print(z.namelist())
    csv_file = [f for f in z.namelist() if f.endswith(".csv")][0]
    acs = pd.read_csv(z.open(csv_file))

acs.head(2)
['nhgis0078_csv/nhgis0078_ds254_20215_blck_grp_codebook.txt', 'nhgis0078_csv/nhgis0078_ds254_20215_blck_grp.csv']
GISJOIN YEAR STUSAB REGIONA DIVISIONA STATE STATEA COUNTY COUNTYA COUSUBA ... GEO_ID BTTRA BTBGA TL_GEO_ID NAME_E AON4E001 AOQIE001 NAME_M AON4M001 AOQIM001
0 G06000104001001 2017-2021 CA NaN NaN California 6 Alameda County 1 NaN ... 1500000US060014001001 NaN NaN 60014001001 Block Group 1, Census Tract 4001, Alameda Coun... 1963 190179.0 Block Group 1, Census Tract 4001, Alameda Coun... 457 92660.0
1 G06000104001002 2017-2021 CA NaN NaN California 6 Alameda County 1 NaN ... 1500000US060014001002 NaN NaN 60014001002 Block Group 2, Census Tract 4001, Alameda Coun... 1361 233125.0 Block Group 2, Census Tract 4001, Alameda Coun... 273 40639.0

2 rows × 47 columns

Key variables in nhgis0078_ds254_20215_blck_grp.csv are listed below:

  1. Filter the downloaded CBG records by state and county FIPS codes to obtain the ACS attributes for each MSA.
msa_counties = {
    "TCMA": ("27", TCMA_county_fips),
    "LA": ("06", LA_county_fips),
    "Houston": ("48", Houston_county_fips),
    "Seattle": ("53", Seattle_county_fips),
    "Miami": ("12", Miami_county_fips),
    "Atlanta": ("13", Atlanta_county_fips),
    "Denver": ("08", Denver_county_fips)
}

msa_acs = {}

for name, (state, counties) in msa_counties.items():
    msa_acs[name] = acs[
        (acs["STATEA"].astype(str).str.zfill(2) == state) &
        (acs["COUNTYA"].astype(str).str.zfill(3).isin(counties))
    ].copy()

Data visualization

Link each msa_acs table to its corresponding CBG boundary by using GEOID and GEO_ID:

def link_acs_boundary(acs_df, boundary):
    acs_df = acs_df.copy()
    boundary = boundary.copy()
    acs_df["GEOID"] = acs_df["GEO_ID"].str[-12:] # match the GEOID and GEO_ID by their structures
    boundary["GEOID"] = boundary["GEOID"].astype(str)
    return boundary.merge(acs_df, on="GEOID", how="left")

msa_boundaries = {
    "TCMA": TCMA_cbg,
    "LA": LA_cbg,
    "Houston": Houston_cbg,
    "Seattle": Seattle_cbg,
    "Miami": Miami_cbg,
    "Atlanta": Atlanta_cbg,
    "Denver": Denver_cbg
}

msa_cbg_acs = {name: link_acs_boundary(msa_acs[name], boundary) for name, boundary in msa_boundaries.items()}

TCMA_cbg_acs = msa_cbg_acs["TCMA"]
LA_cbg_acs = msa_cbg_acs["LA"]
Houston_cbg_acs = msa_cbg_acs["Houston"]
Seattle_cbg_acs = msa_cbg_acs["Seattle"]
Miami_cbg_acs = msa_cbg_acs["Miami"]
Atlanta_cbg_acs = msa_cbg_acs["Atlanta"]
Denver_cbg_acs = msa_cbg_acs["Denver"]

Verify the spatial join:

for name, gdf in msa_cbg_acs.items():
    print(name, len(gdf), gdf["GEO_ID"].notna().sum())
TCMA 2568 2568
LA 8640 8640
Houston 4176 4176
Seattle 2738 2738
Miami 3946 3946
Atlanta 3950 3950
Denver 2048 2048

Mapping:

def map_msa_socio(gdf, name, population_col="AON4E001", income_col="AOQIE001"):
    gdf = gdf.copy()
    gdf_proj = gdf.to_crs(gdf.estimate_utm_crs())
    gdf["pop_density"] = gdf[population_col] / (gdf_proj.geometry.area / 1e6) # calculate the population density

    fig, axes = plt.subplots(1, 2, figsize=(14, 6))
    gdf.plot(column="pop_density", cmap="YlOrRd", scheme="quantiles", k=5, legend=True, ax=axes[0], edgecolor="none")
    axes[0].set_title(f"{name} - Population Density")  # use pop density instead
    axes[0].axis("off")

    gdf.plot(column=income_col, cmap="YlGnBu", scheme="quantiles", k=5, legend=True, ax=axes[1], edgecolor="none")
    axes[1].set_title(f"{name} - Median Household Income")
    axes[1].axis("off")

    plt.tight_layout()
    plt.show()
    return gdf
for name, gdf in msa_cbg_acs.items():
    msa_cbg_acs[name] = map_msa_socio(gdf, name)

Violin plots to compare the statistical distributions across MSAs:

def violin_msa_socio(msa_cbg_acs, population_col="AON4E001", income_col="AOQIE001",
                     density_symlog=False, save_path=None):
    records = []
    for name, gdf in msa_cbg_acs.items():
        if gdf.empty:
            continue
        area = gdf.to_crs(gdf.estimate_utm_crs()).geometry.area / 1e6
        population = pd.to_numeric(gdf[population_col], errors="coerce")
        income = pd.to_numeric(gdf[income_col], errors="coerce")
        records.append(pd.DataFrame({
            "MSA": name,
            "pop_density": population.where(population >= 0) / area.where(area > 0),
            "income": income.where(income >= 0),
        }))

    df = pd.concat(records, ignore_index=True).replace([np.inf, -np.inf], np.nan)
    order = df.groupby("MSA")["pop_density"].median().sort_values(ascending=False).index

    with sns.axes_style("whitegrid"):
        fig, axes = plt.subplots(1, 2, figsize=(13, max(4, 0.45 * len(order) + 1.5)),
                                 sharey=True, layout="constrained")
        panels = [
            ("pop_density", "Population density", "People/km²", "#D96B6B"),
            ("income", "Median household income", "US dollars", "#55AFA5"),
        ]
        for ax, (column, title, xlabel, color) in zip(axes, panels):
            sns.violinplot(data=df, x=column, y="MSA", order=order, orient="h",
                           color=color, inner="quart", cut=0, linewidth=0.8, saturation=1, ax=ax)
            ax.set(title=title, xlabel=xlabel, ylabel="", xlim=(0, None))
            ax.set_axisbelow(True)
            ax.grid(axis="x", color="#E5E5E5", linewidth=0.7)
            ax.grid(axis="y", visible=False)
            ax.tick_params(axis="y", length=0)
            sns.despine(ax=ax, left=True)

        if density_symlog:
            axes[0].set_xscale("symlog", linthresh=100)
            axes[0].set_xlabel("People/km² (symlog; linear below 100)")

        axes[0].xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"{x:,.0f}"))
        axes[1].xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"${x:,.0f}"))

        if save_path:
            fig.savefig(save_path, dpi=600, bbox_inches="tight")
        plt.show()

    return df


socio_df = violin_msa_socio(msa_cbg_acs, density_symlog=True)

Joint exploration: four density–income profiles

For each MSA, classify all CBGs into four density–income profiles using median thresholds and map the spatial distribution of profiles across CBGs within each MSA.

from matplotlib.patches import Patch

def map_joint_socio(msa_cbg_acs, population_col="AON4E001", income_col="AOQIE001",
                    within_msa=True, ncols=2, save_path=None):
    records, crs_by_msa = [], {}
    for name, gdf in msa_cbg_acs.items():
        if gdf.empty:
            continue
        crs_by_msa[name] = gdf.estimate_utm_crs()
        area = gdf.to_crs(crs_by_msa[name]).geometry.area / 1e6
        g = gdf.to_crs(4326)[["geometry"]].copy()
        population = pd.to_numeric(gdf[population_col], errors="coerce")
        income = pd.to_numeric(gdf[income_col], errors="coerce")
        g["MSA"] = name
        g["pop_density"] = population.where(population >= 0) / area.where(area > 0)
        g["income"] = income.where(income >= 0)
        records.append(g)

    maps = gpd.GeoDataFrame(pd.concat(records, ignore_index=True), crs=4326)
    cols = ["pop_density", "income"]
    maps[cols] = maps[cols].replace([np.inf, -np.inf], np.nan)
    valid = maps.dropna(subset=cols).copy()
    thresholds = valid.groupby("MSA")[cols].transform("median") if within_msa else valid[cols].median()
    high = valid[cols].ge(thresholds)

    labels = [
        "High density · Low income",
        "High density · High income",
        "Low density · Low income",
        "Low density · High income",
    ]
    colors = dict(zip(labels, ["#D96B6B", "#8064A2", "#D8D8D8", "#55AFA5"]))
    valid["profile"] = np.select(
        [high.pop_density & ~high.income, high.pop_density & high.income,
         ~high.pop_density & ~high.income],
        labels[:3], default=labels[3],
    )
    maps["profile"] = valid["profile"].reindex(maps.index).fillna("No data")
    colors["No data"] = "#F2F2F2"

    names = list(crs_by_msa)
    ncols = min(max(1, ncols), len(names))
    nrows = int(np.ceil(len(names) / ncols))
    fig, axes = plt.subplots(nrows, ncols, figsize=(6 * ncols, 5 * nrows), squeeze=False)

    for ax, name in zip(axes.flat, names):
        g = maps.loc[maps["MSA"].eq(name)].to_crs(crs_by_msa[name])
        g.plot(ax=ax, color=g["profile"].map(colors), edgecolor="white", linewidth=0.1)
        ax.set_title(name, fontsize=12, fontweight="bold")
        ax.set_axis_off()

    for ax in axes.flat[len(names):]:
        ax.set_axis_off()

    scope = "Within-MSA" if within_msa else "Pooled"
    fig.suptitle(f"Joint socioeconomic profiles — {scope.lower()} median thresholds", fontsize=14)
    fig.legend(handles=[Patch(facecolor=c, label=s) for s, c in colors.items()],
               loc="lower center", ncol=3, frameon=False)
    fig.tight_layout(rect=(0, 0.09, 1, 0.95))
    if save_path:
        fig.savefig(save_path, dpi=300, bbox_inches="tight")
    plt.show()

    return maps


profile_maps = map_joint_socio(msa_cbg_acs, within_msa=True)

Calculates and plots the percentage of CBGs in each density–income profile within each MSA:

def joint_socio_profiles(socio_df, within_msa=True, save_path=None):
    df = socio_df.copy()
    cols = ["pop_density", "income"]
    df[cols] = df[cols].apply(pd.to_numeric, errors="coerce")
    df[cols] = df[cols].replace([np.inf, -np.inf], np.nan)
    df = df.dropna(subset=["MSA"] + cols)
    df = df.loc[df[cols].ge(0).all(axis=1)].copy()

    thresholds = df.groupby("MSA")[cols].transform("median") if within_msa else df[cols].median()
    high = df[cols].ge(thresholds)

    labels = [
        "High density · Low income",
        "High density · High income",
        "Low density · Low income",
        "Low density · High income",
    ]
    df["profile"] = np.select(
        [high.pop_density & ~high.income, high.pop_density & high.income,
         ~high.pop_density & ~high.income],
        labels[:3], default=labels[3],
    )

    shares = pd.crosstab(df["MSA"], df["profile"], normalize="index")
    shares = shares.reindex(columns=labels, fill_value=0).mul(100)
    shares = shares.sort_values(labels[0], ascending=False)

    ax = shares.plot.barh(stacked=True, figsize=(11, max(4, 0.45 * len(shares))),
                          color=["#D96B6B", "#8064A2", "#D8D8D8", "#55AFA5"], width=0.75)
    for container in ax.containers:
        ax.bar_label(container, labels=[f"{v:.0f}%" if v >= 8 else "" for v in container.datavalues],
                     label_type="center", fontsize=9)

    ax.invert_yaxis()
    ax.set(xlim=(0, 100), xlabel="Share of valid CBGs (%)", ylabel="",
           title="Joint socioeconomic profiles")
    ax.legend(title=None, loc="upper center", bbox_to_anchor=(0.5, -0.16), ncol=2, frameon=False)
    ax.spines[["top", "right"]].set_visible(False)
    plt.tight_layout()
    if save_path:
        ax.figure.savefig(save_path, dpi=300, bbox_inches="tight")
    plt.show()

    return df, shares


profile_df, profile_shares = joint_socio_profiles(socio_df)

Summary

After running the notebook, you will have:

The resulting MSA-level GeoDataFrames can be exported or reused as resident-census inputs in downstream analyses.