Allocating ZIP Code Case Counts to Census Tracts
Cases arrive by ZIP code because that is what intake forms collect; denominators and deprivation measures live on census tracts because that is what the census publishes. Bridging the two is the most frequently performed interpolation in public health analysis and the one most often performed without acknowledgement. This guide, part of Areal Interpolation & Boundary Harmonization, sets out the procedure and the conditions under which it should be refused.
Problem Context & Constraints
The first obstacle is that a ZIP code is not a polygon. It is a set of delivery routes maintained by the postal service, it has no official boundary, it changes without notice, and a small number of ZIPs are single buildings or PO box ranges with no residential area at all. What analysts actually use are ZIP Code Tabulation Areas — census-constructed approximations built from blocks — and the substitution is usually silent.
The gap between the two is not negligible. ZCTAs are built to approximate the most common ZIP in each block, so a block whose addresses split across two ZIPs is assigned to one of them entirely. Point-level studies comparing ZIP-of-record to ZCTA-of-coordinate typically find several percent of records land in a different area. That error is upstream of everything this guide does and cannot be corrected downstream.
The second obstacle is size. ZCTAs are on average several times larger than tracts and vary enormously, from a few blocks in a city to hundreds of square kilometres in a rural county. As the parent guide’s error curve shows, interpolation error grows with that ratio, so the same procedure is defensible in a city and indefensible in a rural county — within the same run.
Prerequisites
- ZCTA boundaries and tract boundaries for the study area, same vintage year, in a common equal-area CRS
- A census block layer with population for weighting
- Case counts by ZIP as reported, plus the count of cases whose ZIP did not resolve to a ZCTA at all
python3.11,geopandas1.0.1,pandas2.2.2
Before allocating anything, count how many targets each source touches — it identifies the problem ZCTAs before any case is moved:
Step-by-Step Solution
# Allocate ZIP-reported case counts onto census tracts, with per-ZCTA diagnostics.
# 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.zip2tract")
EQUAL_AREA = "EPSG:5070"
MAX_SAFE_RATIO = 5.0 # above this, flag the ZCTA rather than trusting its split
def zcta_tract_weights(zcta: gpd.GeoDataFrame, tracts: gpd.GeoDataFrame,
blocks: gpd.GeoDataFrame, pop_col: str = "pop") -> pd.DataFrame:
z = zcta.to_crs(EQUAL_AREA)[["zcta", "geometry"]]
t = tracts.to_crs(EQUAL_AREA)[["geoid", "geometry"]]
b = blocks.to_crs(EQUAL_AREA).copy()
b["geometry"] = b.geometry.representative_point()
b = gpd.sjoin(b, z, how="left", predicate="within").drop(columns="index_right")
b = gpd.sjoin(b, t, how="left", predicate="within").drop(columns="index_right")
cw = (b.dropna(subset=["zcta", "geoid"])
.groupby(["zcta", "geoid"])[pop_col].sum().reset_index())
tot = cw.groupby("zcta")[pop_col].transform("sum")
cw["w"] = np.where(tot > 0, cw[pop_col] / tot, 0.0)
# Per-ZCTA diagnostics: how many tracts it touches, and how concentrated the split is.
diag = cw.groupby("zcta").agg(
n_tracts=("geoid", "nunique"),
max_w=("w", "max"),
entropy=("w", lambda s: float(-(s[s > 0] * np.log(s[s > 0])).sum())),
)
log.info("ZCTAs touching >5 tracts: %d of %d",
int((diag["n_tracts"] > 5).sum()), len(diag))
return cw.merge(diag.reset_index(), on="zcta")
def allocate(cases_by_zcta: pd.DataFrame, cw: pd.DataFrame,
value_col: str = "cases") -> pd.DataFrame:
"""Distribute counts and carry the diagnostics through to the tract level."""
m = cw.merge(cases_by_zcta, on="zcta", how="left")
missing = m[value_col].isna().sum()
if missing:
log.warning("%d crosswalk rows had no case count and were treated as zero", int(missing))
m[value_col] = m[value_col].fillna(0.0) * m["w"]
out = m.groupby("geoid").agg(
**{value_col: (value_col, "sum")},
# A tract fed only by highly split ZCTAs is a tract whose value is mostly assumption.
min_source_max_w=("max_w", "min"),
n_source_zctas=("zcta", "nunique"),
).reset_index()
src, tgt = cases_by_zcta[value_col].sum(), out[value_col].sum()
assert abs(src - tgt) < 1e-6 * max(1.0, src), f"conservation failed: {src} != {tgt}"
log.info("allocated %.0f cases from %d ZCTAs to %d tracts",
tgt, cases_by_zcta["zcta"].nunique(), len(out))
return out
Carrying min_source_max_w through to the tract level is what makes the result honest. A tract whose cases came entirely from a ZCTA that split evenly across eleven tracts has a value that is nine parts assumption; a tract fed by a ZCTA that contributed 94% of its population to it does not. Those two tracts look identical in a choropleth unless the diagnostic travels with them.
Validation & Edge Cases
1. Account for ZIPs that are not ZCTAs. Every run should report the case count that failed to join, and it is never zero. PO box ranges, single-building ZIPs and retired codes all appear in intake data and have no tabulation area:
INFO ZCTAs touching >5 tracts: 38 of 214
WARNING 1,204 cases were reported under 41 ZIPs with no matching ZCTA (2.9% of the file)
INFO allocated 40,803 cases from 214 ZCTAs to 908 tracts
2. Refuse the transfer where the ratio is extreme. Set an explicit rule — a ZCTA covering more than five tracts, or contributing under 20% of its population to its largest tract, is flagged — and either publish those areas at ZCTA level or mark the affected tracts as low-confidence. Silently producing a tract number for them is the failure this guide exists to prevent.
3. Check the reverse direction for a sanity test. Aggregate the allocated tract values back to ZCTAs. They must reproduce the input exactly; if they do not, the crosswalk has a coverage hole.
4. Do not allocate and then cluster without adjustment. Allocated tract values within one ZCTA are perfectly correlated by construction, and a Getis-Ord Gi* run on them will find “clusters” that are exactly the shapes of the source ZCTAs. If the map of significant tracts resembles the ZCTA boundaries, that is the artefact, not a finding.
5. Check whether the analysis actually needs tracts. ZIP-to-tract allocation is often performed because the covariates are published on tracts, not because the analysis requires tract resolution. Where that is the case, the cheaper and more defensible move is the reverse transfer: aggregate the tract covariates up to ZCTAs and analyse on the geography the outcome arrived on. The covariate transfer is population-weighted in the same way and introduces error into a variable that is usually smoother and better measured than the case count.
6. Watch for ZCTAs that are mostly non-residential. A ZCTA covering an industrial district or an airport may have almost no residential population, so its crosswalk fractions rest on a handful of blocks and are extremely unstable. These are the same zones flagged in the parent guide as zero-weight sources, and they should be listed explicitly rather than allowed to distribute their cases according to a denominator of forty people.
7. Prefer ZCTA-level publication for anything policy-facing. Where a result will be quoted in a funding decision, publishing at the geography the data arrived on removes an entire class of challenge and loses very little, since most policy geographies are coarser than a tract anyway.
The confidence diagnostic is easy to compute and easy to drop before publication, which is the failure it exists to prevent:
Compliance Notes
- Publish the unresolved-ZIP count beside the allocated total. It is a completeness figure and it belongs in the same table as the case count.
- Mark allocated tract values as derived, and carry the per-tract confidence diagnostic into the published attribute table rather than dropping it at the last step.
- Re-run suppression after allocation. Fractional case counts below one are common and their disclosure implications are not obvious; the suppression rule must be applied to the published tract values, not to the source ZCTA counts.
- Fix the ZCTA vintage in the run signature. ZCTA boundaries are re-derived each decade and adjusted between, so an allocation is only reproducible against a stated vintage.
Related Topics
- Areal Interpolation & Boundary Harmonization — the parent guide, covering method choice and the conservation gate.
- Crosswalking 2010 to 2020 Census Tracts for Trend Analysis — the same machinery applied across vintages rather than across geographies.
- Geocoding Quality & Address Standardization — the alternative that removes the need for this transfer entirely when addresses are available.