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.

The Same Procedure, Two Very Different Situations On the left, an urban ZCTA covering about three census tracts: the allocation splits its cases across a small number of similar-sized units and the error is modest. On the right, a rural ZCTA covering a single very large tract plus fragments of two others: nearly all of its cases belong to one tract and the allocation is nearly exact, but a third ZCTA spanning eleven tracts in between is where the error concentrates. Size ratio, not method, decides which situation you are in. The size ratio decides whether the allocation means anything urban ZCTA mixed fringe ZCTA rural ZCTA 3 tracts · ratio 1.4× allocation is safe 11 tracts · ratio 9.6× this is where error lives 1 tract + fragments nearly exact by luck One run, three regimes — report the ratio per ZCTA, not per study a county-wide “average ratio of 4×” hides both the safe cases and the unusable ones and the middle panel is where every published error comes from

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
  • python 3.11, geopandas 1.0.1, pandas 2.2.2

Before allocating anything, count how many targets each source touches — it identifies the problem ZCTAs before any case is moved:

How Many Tracts a ZCTA Touches Distribution of the number of census tracts each ZCTA overlaps, across 214 ZCTAs. Ninety-one touch one or two tracts and allocate almost exactly. Eighty-five touch three to five. Thirty-eight touch more than five, and those are the ZCTAs whose case counts are distributed largely by assumption. The tail, not the average, decides whether tract-level publication is defensible. The tail is where the assumption lives 91 1–2 tracts 85 3–5 tracts 31 6–10 tracts 7 11+ tracts 38 ZCTAs carry most of the error, and they are identifiable in advance

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:

The Confidence Column That Must Travel With Each Tract Four tracts with the same allocated case count of six. The first receives its cases from a ZCTA that contributed 94 percent of its population to it, so the value is nearly observed. The second from a ZCTA contributing 61 percent. The third from one contributing 33 percent. The fourth from three ZCTAs none of which contributed more than 22 percent, so the value is almost entirely assumption. On a choropleth all four are shaded identically. Four tracts, six cases each, four different claims 6 94% from one ZCTA nearly observed 6 61% from one ZCTA mostly observed 6 33% from one ZCTA mostly assumption 6 3 ZCTAs, max 22% almost entirely assumption A choropleth shades all four identically which is why the confidence column has to be published, not just computed

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.