Delineating Hospital Service Areas From Discharge Flows

This guide, part of Catchment & Service Area Delineation, walks through building utilisation-based hospital service areas from a state inpatient discharge database.

Problem Context & Constraints

A state health department wants hospital service areas for three purposes: to compute facility-level rates of preventable admissions, to supply nonprofit hospitals with a consistent community definition for their needs assessments, and to see where hospital markets overlap. The data are a state inpatient discharge database with patient ZIP code, facility identifier and discharge date for every stay.

The method is the one popularised by the Dartmouth Atlas: each ZIP code is assigned to the hospital that receives the most of its residents’ discharges, and the hospital’s service area is the set of ZIP codes assigned to it. Simple in principle, it runs into four practical problems. Small counts: rural ZIP codes with a few dozen discharges a year have unstable pluralities. Non-residential ZIP codes: post office boxes and unique ZIP codes have no area and must be mapped to the ZIP Code Tabulation Area that contains them. Out-of-state and missing ZIP codes: some records cannot be placed. Suppression: if the analyst works from a public-use file, cells below a threshold are already suppressed.

The constraint that shapes everything else is that the service-area file must be reproducible and explainable: a hospital that disputes its service area should be able to see exactly why each ZIP code was assigned where it was.

From Discharge Records to Service Areas Discharge records carrying patient ZIP code and hospital identifier are cleaned: post office box ZIP codes are mapped to their containing tabulation area, and out-of-state or missing codes are set aside and counted. The cleaned records are pooled into a matrix of ZIP code tabulation areas by hospitals. Each area is assigned to the hospital with the most discharges, and hospitals with their assigned areas form service areas, each area carrying the basis for its assignment. Records, matrix, assignment, service areas Discharges patient ZIP hospital ID, date Clean PO box ZIP to ZCTA set aside unplaceable Matrix ZCTA × hospital 3 years pooled Service areas plurality assignment basis per area count every record set aside — the share that cannot be placed is part of the result

Prerequisites

  • python 3.11
  • pandas 2.2.2, numpy 1.26.4, geopandas 0.14.4
  • A state inpatient discharge database with patient ZIP code, facility identifier and discharge date, under a data use agreement
  • A ZIP code to ZCTA crosswalk for the relevant census vintage, including post office box and unique ZIP codes
  • ZCTA boundaries and population for the same vintage
  • Drive-time nearest-hospital assignments for fallback, from Drive-Time Isochrone Generation
  • A hospital inventory with stable identifiers, merger history, and open and close dates

Step-by-Step Solution

Clean and map ZIP codes to ZCTAs, pool three years, compute plurality with share and count, fall back to travel time for thin areas, and write a versioned file with the basis of every assignment.

# Hospital service areas from state discharge data.
# Pinned: pandas==2.2.2, numpy==1.26.4, geopandas==0.14.4
import hashlib
import logging
from pathlib import Path

import numpy as np
import pandas as pd

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

YEARS = (2023, 2024, 2025)
MIN_DISCHARGES = 20
MIN_SHARE = 0.30


def clean(dis: pd.DataFrame, zip_to_zcta: pd.DataFrame, state_zctas: set) -> pd.DataFrame:
    d = dis[dis["year"].isin(YEARS)].copy()
    d["zip"] = d["patient_zip"].astype(str).str[:5]
    d = d.merge(zip_to_zcta[["zip", "zcta"]], on="zip", how="left")
    reasons = np.select(
        [d["patient_zip"].isna(), d["zcta"].isna(), ~d["zcta"].isin(state_zctas)],
        ["missing_zip", "unmapped_zip", "out_of_state"], default="ok")
    d["exclusion"] = reasons
    counts = d["exclusion"].value_counts()
    log.info("records=%d excluded=%s", len(d), counts.drop("ok", errors="ignore").to_dict())
    return d[d["exclusion"] == "ok"]


def merge_hospital_ids(d: pd.DataFrame, lineage: pd.DataFrame) -> pd.DataFrame:
    """Map historical facility IDs to current IDs so mergers do not split flows."""
    m = dict(zip(lineage["old_id"], lineage["current_id"]))
    d = d.copy()
    d["hospital"] = d["facility_id"].map(lambda x: m.get(x, x))
    return d


def assign(d: pd.DataFrame, nearest: pd.Series) -> pd.DataFrame:
    flows = d.groupby(["zcta", "hospital"]).size().unstack(fill_value=0)
    total = flows.sum(axis=1)
    top = flows.idxmax(axis=1)
    top_n = flows.max(axis=1)
    second = flows.apply(lambda r: r.nlargest(2).iloc[-1] if (r > 0).sum() > 1 else 0, axis=1)
    out = pd.DataFrame({"hospital": top, "discharges": total,
                        "top_share": top_n / total, "margin": (top_n - second) / total})
    out["basis"] = np.where(out["discharges"] < MIN_DISCHARGES, "travel_time",
                    np.where(out["top_share"] < MIN_SHARE, "split", "plurality"))
    thin = out["basis"] == "travel_time"
    out.loc[thin, "hospital"] = nearest.reindex(out.index[thin]).to_numpy()
    return out


def write(out: pd.DataFrame, path: Path, meta: dict) -> str:
    path.parent.mkdir(parents=True, exist_ok=True)
    out.to_csv(path, index_label="zcta")
    digest = hashlib.sha256(path.read_bytes()).hexdigest()[:16]
    pd.Series({**meta, "sha256_16": digest}).to_json(path.with_suffix(".meta.json"))
    log.info("wrote %s: zctas=%d hospitals=%d plurality=%d split=%d travel_time=%d",
             path.name, len(out), out["hospital"].nunique(),
             int((out.basis == "plurality").sum()), int((out.basis == "split").sum()),
             int((out.basis == "travel_time").sum()))
    return digest

The margin column — the lead of the top hospital over the second, as a share of discharges — is the best single indicator of how firmly a ZIP code belongs to its hospital. A 48 percent share with the second hospital at 44 percent is a close contest that could flip next year; a 45 percent share with no other hospital above 10 percent is a firm assignment despite the lower share.

Merging historical facility identifiers into current ones before pooling is essential in states with hospital consolidation. Without it, a hospital that changed identifier mid-period appears as two smaller hospitals, and each loses ZIP codes it would have won combined.

Share Is Not the Same as Firmness A scatter of ZIP code tabulation areas with the top hospital's share on the horizontal axis and its margin over the second hospital on the vertical axis. Points in the upper region have large margins and are firm assignments. Points near the bottom have small margins even when the top share is high, because a second hospital is close behind; these contested areas are the ones likely to change next year. A shaded band marks margins below ten percentage points. Margin over the runner-up tells you what will move contested: margin under 10 points top hospital's share of discharges margin over second a 50% share can still be contested if the runner-up has 44%

Validation & Edge Cases

Unplaceable records. Report the share of records excluded for missing, unmapped or out-of-state ZIP codes, by hospital. A hospital near the state line may lose a quarter of its records to out-of-state residence, which does not change its in-state service area but does change how its volumes should be read.

Post office box ZIP codes. In rural areas many residents use post office boxes with ZIP codes that have no tabulation area of their own. The crosswalk maps them to the containing ZCTA; without it, those discharges vanish and rural ZCTAs fall below the minimum count.

Transfers. A patient admitted to a community hospital and transferred to a tertiary centre generates two discharges. Counting both credits the tertiary centre with a flow that reflects referral, not choice. For service areas, keep the first admission of each episode, or exclude discharges with an admission source of transfer.

Newborns and obstetrics. Births are a large share of discharges and concentrate in hospitals with obstetric units, pulling ZIP codes towards them. For general service areas, exclude normal newborn discharges; for obstetric service areas, use only those.

Hospitals that win no ZIP code. Small hospitals near large ones may be the plurality in no area. They still serve a population. Report them as having no service area under this method, and consider a secondary assignment by share threshold so they can be given a community definition.

Validation against known markets. Compare the resulting service areas with previous delineations, published Dartmouth hospital service areas if the geography matches, or local knowledge from hospital planners. Large disagreements usually trace to a data problem — an identifier change, a missing year — rather than to a real shift.

Service lines hidden inside all-cause flows. A hospital that dominates cardiac surgery across a region but is one of several choices for routine admissions will not show its cardiac reach in an all-cause delineation. Where a program needs service-line catchments — obstetrics, behavioural health, cardiac care — delineate each separately on discharges filtered by diagnosis-related group or service line, and publish them as distinct files rather than as variants of the general one.

Military, veterans’ and tribal facilities. Federal facilities often do not report to state discharge databases. Their patients are missing from the flows, so the ZIP codes around a veterans’ medical centre may be assigned to a community hospital that serves only part of their residents. Note these gaps where the facilities are known.

INFO hsa records=1284310 excluded={'out_of_state': 38211, 'unmapped_zip': 4102, 'missing_zip': 917}
INFO hsa wrote hsa_2023_2025.csv: zctas=1068 hospitals=119 plurality=881 split=74 travel_time=113
How the Areas Were Assigned A stacked bar of one thousand and sixty-eight ZIP code tabulation areas. Eight hundred and eighty-one were assigned by clear plurality. Seventy-four were split, with no hospital receiving thirty percent of discharges. One hundred and thirteen fell back to their nearest hospital by drive time because they had fewer than twenty pooled discharges. The breakdown is published with the service-area file so users know which assignments rest on patient flows. 1,068 areas by basis of assignment 881 plurality 74 split 113 travel time published with the file so users know which assignments rest on patient flows

Compliance Notes

Store the discharge years, inclusion and exclusion rules, the ZIP to ZCTA crosswalk version, the facility lineage table, thresholds, the fallback network and date, and the file hash. Publish the service-area file with its basis column and the assignment breakdown.

The discharge data are protected health information under the state’s data use agreement. The service-area file, which lists areas, hospitals and bases, is generally releasable. The discharge counts, shares and margins by ZIP code and hospital are not, because a small ZIP code’s count at a particular hospital can be identifying. Release the file without counts, or with counts subject to the program’s suppression rules.