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.
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
python3.11,geopandas1.0.1,pandas2.2.2,numpy1.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.
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:
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.
Related Topics
- Areal Interpolation & Boundary Harmonization — the parent guide covering weighting methods and the conservation gate.
- Allocating ZIP Code Case Counts to Census Tracts — the same machinery applied to crossing rather than nested geographies.
- Detecting Coordinate Drift Between Geocoding Vintages — the companion break, where the case moves rather than the boundary.