Most geographic change in the census system happens on decennial schedules – but counties don’t wait for the decade. Since 2000, counties and county equivalents have been created, merged, split, renamed, and transferred between one another a dozen-plus times. A county panel from, say, 2014 to 2023 that ignores these events will silently misalign Alaska, South Dakota, Virginia, and all of Connecticut.
The crosswalk package curates these events from the
Census Bureau’s Substantial
Changes to Counties documentation and ships them as internal package
data, so county -> county crosswalks work between
any pair of years from 2000 onward – offline, with no
API key.
What’s covered
| Event | Type | First vintage with new codes |
|---|---|---|
| Clifton Forge, VA gives up independent-city status | Merge | 2002 |
| Broomfield County, CO created | Split (from parts of four counties) | 2002 |
| Southeast Alaska reorganizations (Skagway, Hoonah-Angoon, Wrangell, Petersburg, Prince of Wales) | Splits/merges | 2008-2009 |
| York County / Newport News, VA territory exchange | Part transfer | 2008 |
| Bedford City, VA rejoins Bedford County | Merge | 2014 |
| Petersburg Borough, AK created | Split/merge | 2014 |
| Shannon County, SD renamed Oglala Lakota County (46113 -> 46102) | FIPS change | 2015 |
| Wade Hampton, AK renamed Kusilvak (02270 -> 02158) | FIPS change | 2015 |
| Valdez-Cordova, AK split into Chugach and Copper River | Split | 2020 |
| Connecticut counties replaced by nine planning regions | Reorganization | 2022 |
A subtlety worth internalizing: the years above are Census Bureau product vintages, not effective dates. The Valdez-Cordova split took legal effect on January 2, 2019, but 2019-vintage ACS products still use the old county; the new codes first appear in 2020-vintage products. The package keys every event to the first product vintage that uses the post-change codes (verified empirically against ACS county inventories), because that’s what determines which codes appear in the data you’re crosswalking.
Basic usage
Request any forward year pair. The result composes all events in the interval on top of an identity mapping over the full county universe – unchanged counties appear with an allocation factor of 1, so the crosswalk is never sparse:
county_14_23 <- get_crosswalk(
source_geography = "county",
target_geography = "county",
source_year = 2014,
target_year = 2023,
silent = TRUE)
crosswalk_14_23 <- county_14_23$crosswalks$step_1
nrow(crosswalk_14_23)
#> [1] 3232
## the counties that changed between 2014 and 2023
crosswalk_14_23 |>
filter(source_geoid != target_geoid)
#> # A tibble: 23 × 9
#> source_geoid target_geoid source_geography_name target_geography_name
#> <chr> <chr> <chr> <chr>
#> 1 02261 02063 county county
#> 2 02261 02066 county county
#> 3 02270 02158 county county
#> 4 09001 09120 county county
#> 5 09001 09140 county county
#> 6 09001 09190 county county
#> 7 09003 09110 county county
#> 8 09003 09140 county county
#> 9 09003 09160 county county
#> 10 09005 09140 county county
#> # ℹ 13 more rows
#> # ℹ 5 more variables: source_year <chr>, target_year <chr>,
#> # allocation_factor_source_to_target <dbl>, weighting_factor <chr>,
#> # state_fips <chr>Renames (like 46113 -> 46102) carry an allocation factor of 1 with
an identity weighting factor. Splits allocate the source
county across its successors with population-based factors:
county_19_21 <- get_crosswalk(
source_geography = "county",
target_geography = "county",
source_year = 2019,
target_year = 2021,
silent = TRUE)
## Valdez-Cordova (02261) split into Chugach (02063) and Copper River (02066)
county_19_21$crosswalks$step_1 |>
filter(source_geoid == "02261")
#> # A tibble: 2 × 9
#> source_geoid target_geoid source_geography_name target_geography_name
#> <chr> <chr> <chr> <chr>
#> 1 02261 02063 county county
#> 2 02261 02066 county county
#> # ℹ 5 more variables: source_year <chr>, target_year <chr>,
#> # allocation_factor_source_to_target <dbl>, weighting_factor <chr>,
#> # state_fips <chr>Applying these crosswalks works exactly as described in
vignette("crosswalk"): pass the result to
crosswalk_data() with your county-level data.
Forward-only, with one exception
County temporal crosswalks require
source_year < target_year:
get_crosswalk(
source_geography = "county",
target_geography = "county",
source_year = 2021,
target_year = 2019)
#> Error in `get_crosswalk()`:
#> ! County crosswalks from 2021 to 2019 are not supported: county temporal crosswalks are forward-only (reversing county changes would require disaggregating merged counties). Swap source_year and target_year to crosswalk forward in time.The asymmetry is inherent: composing a merge forward is just addition, but reversing it would require disaggregating the merged county’s values back into its components, which a crosswalk row cannot honestly express without additional data. Rather than return allocation factors that look precise but aren’t, the package asks you to standardize forward to the latest year in your panel.
Sub-county geographies
County changes ripple into the GEOIDs of everything nested inside a
county: when Shannon County became Oglala Lakota, every tract, block
group, and block in 46113... was relabeled to
46102.... The package serves these relabels for same-decade
year pairs from 2010 onward:
tract_14_19 <- get_crosswalk(
source_geography = "tract",
target_geography = "tract",
source_year = 2014,
target_year = 2019,
silent = TRUE)
## identity rows for all unchanged tracts, relabels for the affected ones
tract_14_19$crosswalks$step_1 |>
filter(source_geoid != target_geoid) |>
head()
#> # A tibble: 4 × 9
#> source_geoid target_geoid source_geography_name target_geography_name
#> <chr> <chr> <chr> <chr>
#> 1 02270000100 02158000100 tract tract
#> 2 46113940500 46102940500 tract tract
#> 3 46113940800 46102940800 tract tract
#> 4 46113940900 46102940900 tract tract
#> # ℹ 5 more variables: source_year <chr>, target_year <chr>,
#> # allocation_factor_source_to_target <dbl>, weighting_factor <chr>,
#> # state_fips <chr>Because these sub-county changes are exact 1:1 relabels, they are also the one case where backward temporal crosswalks are offered (e.g., tract 2019 -> 2014) – reversing a relabel is lossless in a way reversing a merge is not.
Year pairs that span a decennial census (e.g., tract 2014 -> 2023)
can’t be pure relabels, because tract boundaries themselves changed in
2020. For those, get_crosswalk() automatically plans a
chain that routes through NHGIS cross-decade crosswalks (and so requires
an IPUMS_API_KEY):
## planned automatically as: 2014 tracts -[relabel]-> 2010-decade vintage
## -[NHGIS]-> 2020-decade vintage -[relabel]-> 2023 tracts
tract_14_23 <- get_crosswalk(
source_geography = "tract",
target_geography = "tract",
source_year = 2014,
target_year = 2023)Connecticut, briefly
Connecticut’s 2022 replacement of its eight counties with nine
planning-region county equivalents is the most sweeping recent change –
every Connecticut county GEOID changed, and the old and new units don’t
nest. Direct 2020 <-> 2022 requests are served by crosswalks from
the CT Data Collaborative (county-level requests fetch county GEOIDs via
tidycensus and so need a CENSUS_API_KEY);
county requests spanning 2022 (like the 2014 -> 2023 example above)
incorporate the same change through the curated event registry, with
population-based allocation factors where old counties split across
planning regions.
Where the data comes from
The event registry, GEOID mappings, and year-by-year county universes
are built by data-raw/build_county_events_sysdata.R from
Census Bureau documentation and ACS county inventories, and ship as
internal package data (R/sysdata.rda). See data-raw/README.md
for provenance details.