Skip to content

Multi-Year Analysis

Census data is released annually, making it a powerful source for tracking trends over time. This guide covers patterns for pulling the same variables across multiple years, common pitfalls with time-series comparisons, and built-in features that simplify longitudinal work.


Basic loop pattern

The simplest approach is to loop over a range of years and concatenate the results:

import pandas as pd
import pypums

years = range(2018, 2023)  # 2018 through 2022
frames = []

for year in years:
    df = pypums.get_acs(
        geography="state",
        variables="B01001_001",
        state="CA",
        year=year,
        survey="acs5",
    )
    df["year"] = year
    frames.append(df)

trend = pd.concat(frames, ignore_index=True)
print(trend[["year", "NAME", "variable", "estimate"]])
 year           NAME    variable  estimate

0 2018 Mississippi B01001_001 2988762 1 2018 Missouri B01001_001 6090062 2 2018 Montana B01001_001 1041732 3 2018 Nebraska B01001_001 1904760 4 2018 Nevada B01001_001 2922849 .. … … … … 255 2022 Washington B01001_001 7688549 256 2022 West Virginia B01001_001 1792967 257 2022 Wisconsin B01001_001 5882128 258 2022 Wyoming B01001_001 577929 259 2022 Puerto Rico B01001_001 3272382

[260 rows x 4 columns]

This produces a tidy DataFrame with one row per year, ready for plotting or further analysis.


import altair as alt
import pandas as pd
import pypums

states = ["CA", "TX", "FL", "NY"]
years = range(2018, 2023)
frames = []

for year in years:
    for st in states:  # get_acs() accepts one state at a time
        df = pypums.get_acs(
            geography="state",
            variables="B01001_001",
            state=st,
            year=year,
            survey="acs5",
            cache_table=True,  # cache to avoid re-fetching
        )
        df["year"] = year
        frames.append(df)

trend = pd.concat(frames, ignore_index=True)

# Plot.
alt.Chart(trend).mark_line(point=True).encode(
    x=alt.X("year:O", title="Year"),
    y=alt.Y("estimate:Q", title="Population (ACS 5-year estimate)"),
    color="NAME:N",
    tooltip=["NAME", "year:O", "estimate:Q"],
).properties(title="Population Trends, 2018-2022", width=500, height=300)

Interactive preview

{ "$schema": "https://vega.github.io/schema/vega-lite/v5.json", "title": "Population Trends, 2018-2022", "width": 500, "height": 300, "data": { "values": [ {"year": 2018, "NAME": "California", "estimate": 39557045}, {"year": 2019, "NAME": "California", "estimate": 39512223}, {"year": 2020, "NAME": "California", "estimate": 39538223}, {"year": 2021, "NAME": "California", "estimate": 39237836}, {"year": 2022, "NAME": "California", "estimate": 39029342}, {"year": 2018, "NAME": "Texas", "estimate": 28701845}, {"year": 2019, "NAME": "Texas", "estimate": 28995881}, {"year": 2020, "NAME": "Texas", "estimate": 29145505}, {"year": 2021, "NAME": "Texas", "estimate": 29527941}, {"year": 2022, "NAME": "Texas", "estimate": 30029572}, {"year": 2018, "NAME": "Florida", "estimate": 21299325}, {"year": 2019, "NAME": "Florida", "estimate": 21477737}, {"year": 2020, "NAME": "Florida", "estimate": 21538187}, {"year": 2021, "NAME": "Florida", "estimate": 22177997}, {"year": 2022, "NAME": "Florida", "estimate": 22244823}, {"year": 2018, "NAME": "New York", "estimate": 19530351}, {"year": 2019, "NAME": "New York", "estimate": 19453561}, {"year": 2020, "NAME": "New York", "estimate": 20201249}, {"year": 2021, "NAME": "New York", "estimate": 19835913}, {"year": 2022, "NAME": "New York", "estimate": 19677151} ] }, "mark": {"type": "line", "point": true}, "encoding": { "x": {"field": "year", "type": "ordinal", "title": "Year"}, "y": {"field": "estimate", "type": "quantitative", "title": "Population (ACS 5-year estimate)"}, "color": {"field": "NAME", "type": "nominal", "title": "State"}, "tooltip": [ {"field": "NAME", "type": "nominal"}, {"field": "year", "type": "ordinal"}, {"field": "estimate", "type": "quantitative", "format": ","} ] } }

Cache results when pulling many years

Pass cache_table=True to avoid re-fetching data you have already downloaded. This is especially important when you are looping over many years or geographies, since the Census API has rate limits.


Variable code stability

Most Census B-table variable codes (e.g., B01001_001 for total population) are stable from year to year. However, the Census Bureau occasionally introduces new variables, retires old ones, or changes their definitions.

Best practice: check load_variables() for each year you plan to use:

vars_2018 = pypums.load_variables(2018, "acs5", cache=True)
vars_2022 = pypums.load_variables(2022, "acs5", cache=True)

# Check if a specific variable exists in both years.
target = "B19013_001"  # Median household income
print(target in vars_2018["name"].values)  # True
print(target in vars_2022["name"].values)  # True

# Look for label changes.
label_2018 = vars_2018.loc[vars_2018["name"] == target, "label"].iloc[0]
label_2022 = vars_2022.loc[vars_2022["name"] == target, "label"].iloc[0]
print(f"2018: {label_2018}")
print(f"2022: {label_2022}")
True
True
2018: Estimate!!Median household income in the past 12 months (in 2018 inflation-adjusted dollars)
2022: Estimate!!Median household income in the past 12 months (in 2022 inflation-adjusted dollars)

Subject and profile tables change more often

S-table (Subject) and DP-table (Data Profile) variable codes are less stable across years than B-table codes. Always verify before building a time series with these tables.


ACS 1-year vs. 5-year

The Census Bureau publishes two main ACS products. Choosing the right one is critical for multi-year analysis.

Product Population threshold Geographies available Period covered
ACS 1-year (acs1) 65,000+ States, large counties, large cities Single calendar year
ACS 5-year (acs5) None All geographies down to block group Rolling 5-year period
# Only available for geographies with 65,000+ population.
df = pypums.get_acs(
    geography="state",
    variables="B01001_001",
    year=2022,
    survey="acs1",
)
print(f"{len(df)} states")
print(df.head())

52 states GEOID NAME variable estimate moe 0 01 Alabama B01001_001 5074296 -555555555 1 02 Alaska B01001_001 733583 -555555555 2 04 Arizona B01001_001 7359197 -555555555 3 05 Arkansas B01001_001 3045637 -555555555 4 06 California B01001_001 39029342 -555555555

# Available for all geographies, including tracts.
df = pypums.get_acs(
    geography="tract",
    variables="B01001_001",
    state="CA",
    county="037",
    year=2022,
    survey="acs5",
)
print(f"{len(df)} tracts in Los Angeles County")

2498 tracts in Los Angeles County

Year interpretation for ACS 5-year

An ACS 5-year dataset labeled “2022” actually covers the 2018–2022 period. The estimate represents an average over those five years, not a snapshot of 2022 alone.

Dataset label Period covered
2022 2018–2022
2021 2017–2021
2020 2016–2020
2019 2015–2019
2018 2014–2018

Do not compare overlapping 5-year periods

ACS 5-year estimates from consecutive years share most of their underlying data. For example, 2017–2021 and 2018–2022 share four years of data. Comparing them as if they were independent violates statistical assumptions and can produce misleading trend lines.

Safe comparisons for 5-year data:

  • 2013–2017 vs. 2018–2022 (non-overlapping)
  • Any two periods that share zero years

If you need annual trends, use the ACS 1-year product instead (at the cost of geographic granularity).


Geography boundary changes

Census geography boundaries are not fixed. The most important boundary change happens with each decennial census, when tract and block group boundaries are redrawn.

Vintage Tract/BG boundaries Notes
2010–2019 2010 Census tracts Based on 2010 TIGER/Line
2020–present 2020 Census tracts Based on 2020 TIGER/Line

This means that a tract GEOID like 06037101110 in 2019 data refers to a different physical area than the same GEOID in 2020 data (if it even exists in both vintages).

What to do about boundary changes

The safest approach is to restrict your time series to years that use the same tract definitions:

# All use 2020-vintage tracts.
for year in [2020, 2021, 2022]:
    df = pypums.get_acs(
        geography="tract",
        variables="B01001_001",
        state="CA",
        county="037",
        year=year,
    )

If you need to compare across vintages, use interpolate_pw() to transfer data from one set of boundaries to another:

from pypums.spatial import interpolate_pw

# 2019 data on 2010-vintage tracts.
old_tracts = pypums.get_acs(
    geography="tract",
    variables="B01001_001",
    state="CA",
    county="037",
    year=2019,
    geometry=True,
)

# 2020-vintage tract boundaries.
new_tracts = pypums.get_acs(
    geography="tract",
    variables="B01001_001",
    state="CA",
    county="037",
    year=2022,
    geometry=True,
)

# Interpolate 2019 values onto 2020 boundaries.
interpolated = interpolate_pw(
    old_tracts,
    new_tracts,
    value_col="estimate",
    weight_col="estimate",  # use population as weight
    extensive=True,
)

County and state boundaries rarely change. If your analysis does not require sub-county detail, comparing counties or states across decades is straightforward and avoids boundary issues entirely.


Inflation adjustment

Census income and dollar-denominated variables are reported in nominal (current-year) dollars. To compare dollar values across years, you must adjust for inflation manually.

The Census Bureau does not adjust for inflation in its API responses. A common approach is to use the Consumer Price Index (CPI) from the Bureau of Labor Statistics:

# CPI-U annual averages (illustrative values -- use actual BLS data).
cpi = {
    2018: 251.1,
    2019: 255.7,
    2021: 270.9,
    2022: 292.7,
}
target_year = 2022

frames = []
for year in [2018, 2019, 2021, 2022]:  # skip 2020 (no standard ACS 1-year)
    df = pypums.get_acs(
        geography="state",
        variables="B19013_001",  # Median household income
        state="CA",
        year=year,
        survey="acs1",
        cache_table=True,
    )
    df["year"] = year
    # Adjust to target-year dollars.
    df["estimate_real"] = df["estimate"] * (cpi[target_year] / cpi[year])
    frames.append(df)

trend = pd.concat(frames, ignore_index=True)
print(trend[["year", "estimate", "estimate_real"]])
 year  estimate  estimate_real

0 2018 60773 70841.326563 1 2018 61584 71786.685783 2 2018 20296 23658.459578 3 2018 44717 52125.312226 4 2018 54478 63503.427320 .. … … … 203 2022 91306 91306.000000 204 2022 54329 54329.000000 205 2022 70996 70996.000000 206 2022 70042 70042.000000 207 2022 24112 24112.000000

[208 rows x 3 columns]

Note

For ACS 5-year estimates, the Census Bureau recommends adjusting to the final year of the period using the CPI annual average for that year. For example, the 2018–2022 estimate should be adjusted to 2022 dollars.


Population Estimates time series

The Population Estimates Program (PEP) has a built-in time-series mode that returns data for all years within a vintage in a single API call:

import pypums

df = pypums.get_estimates(
    geography="state",
    product="population",
    vintage=2023,
    state="CA",
    time_series=True,
)

# The result includes DATE_CODE and DATE_DESC columns.
print(df[["NAME", "DATE_CODE", "DATE_DESC", "value"]].head(10))
      NAME  DATE_CODE                    DATE_DESC      value
0  California          1       4/1/2020 Census population  39538223
1  California          2  4/1/2020 population estimates base  39538223
2  California          3        7/1/2020 population estimate  39499738
3  California          4        7/1/2021 population estimate  39142991
4  California          5        7/1/2022 population estimate  38965193
5  California          6        7/1/2023 population estimate  39128162
6  California          7        7/1/2024 population estimate  39431263
7  California          8        7/1/2025 population estimate  39794211

This is more efficient than looping over individual years and avoids the need to concatenate DataFrames manually.

Info

time_series=True is only supported for product="population". For other PEP products, use the loop pattern shown at the top of this guide.


Best practices summary

Practice Reason
Use cache_table=True in loops Avoids re-fetching and respects API rate limits
Check load_variables() per year Variable codes and labels can change
Use ACS 1-year for annual trends Avoids overlapping-period bias
Use ACS 5-year for small geographies 1-year data is not available below 65,000 population
Compare non-overlapping 5-year periods Overlapping periods share data and are not independent
Stay within the same tract vintage Tract boundaries change each decade
Adjust dollar values with CPI Census reports nominal dollars
Use time_series=True for PEP Single API call for all years in a vintage

See Also

  • ACS Data — ACS query patterns and year-over-year comparisons
  • Population Estimates — Using time_series=True for built-in multi-year PEP data
  • Finding Variables — Checking variable availability across years with load_variables()