Crosswalking 2010 to 2020 Census Tracts for Trend Analysis

Census tract boundaries are redrawn every decade, and roughly a quarter of them change. A ten-year disease trend computed on “tract 42003140100” is therefore not a trend for one place unless someone has checked, and the join that produces it succeeds silently either way. This guide, part of Areal Interpolation & Boundary Harmonization, covers building the crosswalk, classifying each relationship, and reporting a trend across the break honestly.

Problem Context & Constraints

Four relationships between vintages account for almost everything. A tract may be unchanged, keeping its geometry and usually its identifier. It may split, when population growth pushes it past the target size, producing two or more 2020 tracts inside one 2010 footprint. It may merge, when population loss brings neighbouring tracts under the floor. And it may be redefined, where the boundary moved without a clean split or merge — usually following a new road or a revised municipal line.

Identifiers do not reliably signal which happened. A split tract’s children often carry suffixed identifiers, which is helpful; a redefined tract frequently keeps its identifier unchanged while its geometry moves, which is not. Joining on the identifier alone therefore produces a trend series that mixes places, and the mixing concentrates in fast-growing and fast-declining areas — precisely where a genuine incidence change is most plausible and most consequential.

The workable constraint is that the Census Bureau publishes block-level relationship files, and blocks are stable enough within a decade to serve as the common currency. Any 2010 tract and any 2020 tract can both be expressed as sets of 2020 blocks, and the crosswalk falls out of that.

The Four Ways a Tract Changes Between Vintages Four pairs of before-and-after shapes. An unchanged tract keeps the same footprint and identifier. A split tract becomes two 2020 tracts inside the same footprint, with suffixed identifiers. Two merged tracts become one. A redefined tract keeps its identifier while its boundary moves to follow a new road, which is the case an identifier join cannot detect. Only the last one is invisible to an identifier join unchanged split merged redefined 2010 2020 1401.00 1401.00 1402.00 1402.01 1402.02 1403 1404 1403.00 1405.00 1405.00 Same identifier, different place — and the join reports a clean match which is why the crosswalk has to be built from geometry, not from the identifier

Prerequisites

  • 2010 and 2020 tract boundary files for the study area, both valid and in a common equal-area CRS
  • A 2020 census block layer with population, which is the common currency for the transfer
  • python 3.11, geopandas 1.0.1, pandas 2.2.2, numpy 1.26.4
  • Case counts by 2010 tract for the historical periods, and by 2020 tract thereafter — or, better, the geocoded points, in which case skip to re-aggregation

Step-by-Step Solution

# Build and classify a 2010-to-2020 tract crosswalk through 2020 blocks.
# Pinned: geopandas==1.0.1, pandas==2.2.2, numpy==1.26.4
import logging
import numpy as np
import pandas as pd
import geopandas as gpd

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("interp.vintage")

EQUAL_AREA = "EPSG:5070"


def tract_crosswalk(t2010: gpd.GeoDataFrame, t2020: gpd.GeoDataFrame,
                    blocks: gpd.GeoDataFrame, pop_col: str = "pop20") -> pd.DataFrame:
    """Population-weighted fractions from each 2010 tract to each 2020 tract."""
    a = t2010.to_crs(EQUAL_AREA)[["geoid10", "geometry"]]
    b = t2020.to_crs(EQUAL_AREA)[["geoid20", "geometry"]]
    blk = blocks.to_crs(EQUAL_AREA).copy()
    blk["geometry"] = blk.geometry.representative_point()

    blk = gpd.sjoin(blk, a, how="left", predicate="within").drop(columns="index_right")
    blk = gpd.sjoin(blk, b, how="left", predicate="within").drop(columns="index_right")

    unassigned = blk["geoid10"].isna() | blk["geoid20"].isna()
    if unassigned.any():
        log.warning("%d blocks (%.0f people) fell outside one of the vintages",
                    int(unassigned.sum()), float(blk.loc[unassigned, pop_col].sum()))

    cw = (blk.dropna(subset=["geoid10", "geoid20"])
             .groupby(["geoid10", "geoid20"])[pop_col].sum().reset_index())
    tot = cw.groupby("geoid10")[pop_col].transform("sum")
    cw["w"] = np.where(tot > 0, cw[pop_col] / tot, 0.0)
    return cw[["geoid10", "geoid20", "w"]]


def classify_relationship(cw: pd.DataFrame, thresh: float = 0.98) -> pd.DataFrame:
    """Label each 2010 tract as unchanged, split, merged or redefined.

    `thresh` is the fraction of population that must stay together for a pair to
    count as a clean one-to-one relationship. It is deliberately below 1.0: a
    handful of people moving across a boundary is a digitisation artefact, not a
    redefinition."""
    fan_out = cw.groupby("geoid10")["geoid20"].nunique()
    fan_in = cw.groupby("geoid20")["geoid10"].nunique()
    top = cw.sort_values("w").groupby("geoid10").tail(1).set_index("geoid10")

    rel = pd.DataFrame(index=fan_out.index)
    rel["n_targets"] = fan_out
    rel["best_w"] = top["w"]
    rel["best_target"] = top["geoid20"]
    rel["n_sources_of_best"] = rel["best_target"].map(fan_in)

    rel["relationship"] = np.select(
        [
            (rel["best_w"] >= thresh) & (rel["n_sources_of_best"] == 1),
            (rel["best_w"] < thresh) & (rel["n_targets"] > 1),
            (rel["best_w"] >= thresh) & (rel["n_sources_of_best"] > 1),
        ],
        ["unchanged", "split", "merged"],
        default="redefined",
    )
    log.info("relationships: %s", rel["relationship"].value_counts().to_dict())
    return rel


def harmonize_series(counts_2010_tracts: pd.DataFrame, cw: pd.DataFrame,
                     value_col: str = "cases") -> pd.DataFrame:
    """Express a pre-2020 count series on 2020 tract geography."""
    m = cw.merge(counts_2010_tracts, on="geoid10", how="left")
    m[value_col] = m[value_col].fillna(0.0) * m["w"]
    out = m.groupby("geoid20")[value_col].sum().reset_index()
    src, tgt = counts_2010_tracts[value_col].sum(), out[value_col].sum()
    assert abs(src - tgt) < 1e-6 * max(1.0, src), f"conservation failed: {src} != {tgt}"
    log.info("harmonized %.0f cases from %d 2010 tracts onto %d 2020 tracts",
             tgt, counts_2010_tracts["geoid10"].nunique(), out["geoid20"].nunique())
    return out

Harmonize forward, onto the newer geography, rather than backward. The newer vintage is the one future data will arrive on, so harmonizing forward means the transformation is applied once to a fixed historical archive rather than repeatedly to every incoming period.

Validation & Edge Cases

1. Reconcile the relationship counts against expectation. Roughly 70–80% of tracts are typically unchanged in a decennial revision, with splits outnumbering merges in growing regions. A run reporting 40% redefined almost certainly has a CRS or validity problem rather than an unusually turbulent county:

INFO relationships: {'unchanged': 682, 'split': 141, 'merged': 38, 'redefined': 47}
WARNING 214 blocks (1,882 people) fell outside one of the vintages
INFO harmonized 18,442 cases from 908 2010 tracts onto 972 2020 tracts

2. Investigate blocks outside a vintage. Coastal and riverine boundaries are refined between vintages, so a small number of blocks legitimately fall outside the older tract layer. A large number indicates a mismatched extent — a state file joined to a county study area, for instance.

3. Do not harmonize the denominator from the same crosswalk. The 2020 population is published on 2020 tracts already; use it directly. Running it through the crosswalk backwards and forwards introduces error into a number that was exact.

4. Watch what harmonization does to variance. A split tract’s historical count is divided between its children in fixed proportion, so the two children’s historical series are perfectly correlated by construction. Any spatial statistic that treats them as independent observations — including the neighbour-based tests in Global & Local Moran’s I Implementation — is working with fewer effective observations than it thinks.

The Discontinuity a Vintage Change Creates A tract's case count plotted over ten years. Joined naively on the identifier, the series drops sharply at 2020 because the tract split and only one child kept the original identifier. Harmonized onto 2020 geography, the series is continuous and shows a gentle rise. The naive series would be read as a fifty-eight percent decline in incidence; nothing about the disease changed. A 58% “decline” that is a boundary change 0 30 60 cases 2020 boundaries adopted joined on identifier harmonized onto 2020 tracts 2016 2021 2025 The break lands exactly on the vintage change, which is the diagnostic signature

5. Sanity-check the break. Plot the county total on both bases across the changeover. The county is stable across a tract revision, so its total must be continuous; a step in the county total means the crosswalk itself is wrong, not the tracts.

6. Decide what to do with tracts that did not exist. New tracts carved from previously unpopulated land have no 2010 predecessor with meaningful population, so their harmonized history is zero — which is correct in the sense that nobody lived there, and misleading in a trend chart that shows a rise from nothing. Mark them, and consider starting their series at first habitation rather than at the series start.

7. Verify the crosswalk against the Census Bureau’s own relationship file where one exists. For US tracts a published block relationship file is available, and comparing your derived fractions against it is a strong check: material disagreement usually means a boundary file of the wrong vintage or an extent mismatch, both of which are easier to find now than after the series is published.

8. Apply the same crosswalk to every variable in the series. Using population weighting for cases and area weighting for a covariate produces a rate whose numerator and denominator live on subtly different geographies. One crosswalk, applied uniformly, is the only version that composes.

The relationship counts from a real state file show where the attention belongs:

Share of Tracts by Relationship Type Relationship classification for 908 tracts in one state. Seventy-five percent are unchanged, sixteen percent split, four percent merged and five percent redefined. The redefined group is small and carries most of the risk, because those tracts keep their identifier while their geometry moves, so an identifier-based join silently mixes places for exactly those forty-seven tracts. 908 tracts, and the risk is in the smallest group unchanged · 682 · 75% split · 141 · 16% merged · 38 · 4% redefined · 47 · 5% identifier unchanged, geometry moved An identifier join is correct for 96% of tracts and silently wrong for the rest

Compliance Notes

  • Publish the vintage with every tract-level figure. “Tract 1402.01, 2020 vintage” is unambiguous; the bare identifier is not, and the ambiguity is invisible to a reader.
  • Persist the crosswalk table alongside the harmonized series, since regenerating it later from re-downloaded boundary files may not reproduce the same fractions.
  • Flag harmonized values as derived. A pre-2020 count on 2020 geography was distributed, not observed, and it must not share a column with observed post-2020 counts.
  • Re-run the disclosure review on harmonized outputs. Splitting a historical count across two children can push both below a suppression threshold that the parent cleared, as covered in Small-Count Cell Suppression in Rate Maps.