How to Measure Distance to the Nearest Hospital in Python

On this page

Problem statement

How far people live from a hospital emergency department is one of the most common questions in health geography, and three choices decide the answer before any distance is computed. Which hospitals count: every licensed facility, or those with an emergency department? Where do people live: at tract centroids, which in rural areas can sit in empty farmland, or at population-weighted centres? And where does the study area end: a state boundary that patients cross every day, or the hospitals they actually use?

This page measures the straight-line distance from every Illinois census tract's centre of population to the nearest hospital with emergency services, using the CMS list of Medicare-certified hospitals geocoded with the Census Bureau's geocoder, and shows how much the answer depends on including hospitals across the state line.

Quick answer

Put hospitals and population centres on the unit sphere, find the nearest hospital with a KD-tree, and convert the chord length to a great-circle distance:

def to_xyz(lat, lon):
    la, lo = np.radians(lat), np.radians(lon)
    return np.column_stack([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)])

tree = cKDTree(to_xyz(HOSP.lat, HOSP.lon))
chord, idx = tree.query(to_xyz(POP.LATITUDE, POP.LONGITUDE))
POP["km"] = 2 * 6371.0 * np.arcsin(chord / 2)                       # chord on the unit sphere → great-circle km
POP["nearest"] = HOSP["Facility Name"].to_numpy()[idx]
w = POP.POPULATION
print(f"{len(POP):,} tracts, {w.sum():,} people; population-weighted median distance {np.median(np.repeat(POP.km, w)):.1f} km")
for d in (10, 20, 30):
    print(f"  more than {d} km from an emergency department: {w[POP.km > d].sum() / w.sum():.1%}")
3,261 tracts, 12,812,508 people; population-weighted median distance 4.0 km
  more than 10 km from an emergency department: 16.6%
  more than 20 km from an emergency department: 4.1%
  more than 30 km from an emergency department: 0.5%
Map of Illinois census tracts shaded by straight-line distance from each tract's centre of population to the nearest hospital emergency department, in five classes from under 5 kilometres to over 30, with hospitals in Illinois and the neighbouring states as points, geocoded addresses as circles and ZIP-centre placements as triangles: Chicago and the cities are under 5 kilometres, most rural tracts 10 to 20, and a few rural areas in the east and centre over 30. Map of Illinois census tracts shaded by straight-line distance from each tract's centre of population to the nearest hospital emergency department, in five classes from under 5 kilometres to over 30, with hospitals in Illinois and the neighbouring states as points, geocoded addresses as circles and ZIP-centre placements as triangles: Chicago and the cities are under 5 kilometres, most rural tracts 10 to 20, and a few rural areas in the east and centre over 30.
Distance to emergency care across Illinois.

Which hospitals count, checked geocodes, distance from homes

1. Decide which hospitals count

The CMS file has an Emergency Services flag, and it is not always right. Hospitals with an emergency department report its waiting times to CMS (measure OP_18b), which gives a second source to check the flag against:

il = h[h.State == "IL"]
print(f"Illinois hospitals: {len(il)}; flagged with emergency services: {(il['Emergency Services'] == 'Yes').sum()}")
odd = il[(il["Emergency Services"] == "No") & il["Facility ID"].isin(REPORTS_ED)]
print(f"OP_18b period: {op18['Start Date'].mode()[0]} to {op18['End Date'].mode()[0]}")
print("flagged No but reporting emergency department times:", "; ".join(odd["Facility Name"].str.title() + " (" + odd["City/Town"].str.title() + ")"))
print(f"flagged Yes but psychiatric or children's: {((il['Emergency Services'] == 'Yes') & ~GENERAL[il.index]).sum()}; "
      f"used here: {(HOSP.State == 'IL').sum()} in Illinois, {len(HOSP)} with the neighbouring states")
Illinois hospitals: 193; flagged with emergency services: 175
OP_18b period: 10/01/2024 to 09/30/2025
flagged No but reporting emergency department times: West Suburban Medical Center (Oak Park); Osf Saint Anthony'S Health Center (Alton); Saint James Hospital (Pontiac)
flagged Yes but psychiatric or children's: 5; used here: 173 in Illinois, 710 with the neighbouring states

The three hospitals flagged No reported median emergency department times for hundreds of visits in the year to September 2025, so they had working emergency departments then. Whichever source is out of date, a list of facilities should be checked against a second one before distances are computed from it.

2. Geocode the hospitals and check the matches

The CMS file has addresses but no coordinates. The Census Bureau's batch geocoder takes up to 10,000 addresses per request; some will not match, and those must not be silently dropped:

print(HOSP.how.value_counts().to_string())
print("unmatched in Illinois, placed at their ZIP code:", ", ".join(HOSP[(HOSP.how == "ZIP centre") & (HOSP.State == "IL")]["Facility Name"].str.title().head(5)), "...")
ok = HOSP[HOSP.how == "address"].assign(zlat=lambda d: d["ZIP Code"].str[:5].map(zip_pt.INTPTLAT),
                                        zlon=lambda d: d["ZIP Code"].str[:5].map(zip_pt.INTPTLONG)).dropna(subset=["zlat"])
err = Geod(ellps="WGS84").inv(ok.lon.to_numpy(), ok.lat.to_numpy(), ok.zlon.to_numpy(), ok.zlat.to_numpy())[2] / 1000
print(f"for matched hospitals, the ZIP centre is {np.median(err):.1f} km from the address (median), {np.percentile(err, 90):.1f} km at the 90th percentile")
how
address       625
ZIP centre     85
unmatched in Illinois, placed at their ZIP code: Osf Saint Anthony'S Health Center, Palos Community Hospital, Uchicago Medicine Adventhealth La Grange, Hines Va Medical Center, Swedish Hospital ...
for matched hospitals, the ZIP centre is 2.6 km from the address (median), 6.4 km at the 90th percentile

The matched hospitals show how far off a ZIP centre is likely to be: small against rural distances, not against urban ones. Keep the flag, so the approximate positions can be found again.

3. Measure from where people live

Tract centroids are the centres of the tract's area. The Census Bureau's centres of population are the averages of where the tract's residents live, and in large rural tracts the two can be far apart:

tr = TRACTS.merge(POP[["GEOID", "LATITUDE", "LONGITUDE"]], on="GEOID")
cen = tr.geometry.to_crs(5070).centroid.to_crs(4326)
gap = Geod(ellps="WGS84").inv(cen.x.to_numpy(), cen.y.to_numpy(), tr.LONGITUDE.to_numpy(), tr.LATITUDE.to_numpy())[2] / 1000
print(f"distance between tract centroid and centre of population: median {np.median(gap):.2f} km, "
      f"90th percentile {np.percentile(gap, 90):.1f} km, largest {gap.max():.1f} km")
distance between tract centroid and centre of population: median 0.24 km, 90th percentile 2.2 km, largest 9.7 km

4. Include the hospitals across the border

Illinois residents along the Mississippi, the Ohio and the Indiana line use hospitals in neighbouring states. Measuring only to Illinois hospitals overstates their distances:

il_only = HOSP[HOSP.State == "IL"].reset_index(drop=True)
chord_il, _ = cKDTree(to_xyz(il_only.lat, il_only.lon)).query(to_xyz(POP.LATITUDE, POP.LONGITUDE))
POP["km_il_only"] = 2 * 6371.0 * np.arcsin(chord_il / 2)
moved = POP.km_il_only - POP.km
print(f"tracts whose nearest emergency department is out of state: {(moved > 0).sum()} ({w[moved > 0].sum():,} people)")
print(f"share of people over 30 km away: {w[POP.km_il_only > 30].sum() / w.sum():.2%} with Illinois hospitals only, "
      f"{w[POP.km > 30].sum() / w.sum():.2%} with neighbours'")
tracts whose nearest emergency department is out of state: 57 (212,358 people)
share of people over 30 km away: 0.63% with Illinois hospitals only, 0.54% with neighbours'
Map of Illinois highlighting the 57 tracts whose nearest emergency department is in a neighbouring state, shaded by the kilometres saved, up to 15: along the Mississippi opposite Clinton and Burlington in Iowa, in the Rockford area near Beloit, Wisconsin, in Jo Daviess County, at the southern tip opposite Cape Girardeau, in south Cook and Will counties near hospitals in northwest Indiana, and near St. Louis and Kenosha. Map of Illinois highlighting the 57 tracts whose nearest emergency department is in a neighbouring state, shaded by the kilometres saved, up to 15: along the Mississippi opposite Clinton and Burlington in Iowa, in the Rockford area near Beloit, Wisconsin, in Jo Daviess County, at the southern tip opposite Cape Girardeau, in south Cook and Will counties near hospitals in northwest Indiana, and near St. Louis and Kenosha.
Where the state line would have overstated distances.

How often the nearest is a critical access hospital

The data used on this page

The CMS Hospital General Information file (Medicare-certified hospitals, with an Emergency Services flag), filtered to Illinois and its five neighbouring states; the Census Bureau's batch geocoder for coordinates, with unmatched addresses placed at the internal point of their ZIP code tabulation area from the 2023 Gazetteer; the 2020 centres of population of Illinois tracts; and the 2023 tract boundary file.

import io
import zipfile
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests
from pyproj import Geod
from scipy.spatial import cKDTree

Path("data").mkdir(exist_ok=True)
meta = requests.get("https://data.cms.gov/provider-data/api/1/metastore/schemas/dataset/items/xubh-q36u", timeout=60).json()
meta_ed = requests.get("https://data.cms.gov/provider-data/api/1/metastore/schemas/dataset/items/yv7e-xc69", timeout=60).json()
SRC = {"Hospital_General_Information.csv": meta["distribution"][0]["downloadURL"],
       "Timely_and_Effective_Care-Hospital.csv": meta_ed["distribution"][0]["downloadURL"],
       "CenPop2020_Mean_TR17.txt": "https://www2.census.gov/geo/docs/reference/cenpop2020/tract/CenPop2020_Mean_TR17.txt",
       "gaz_zcta.zip": "https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2023_Gazetteer/2023_Gaz_zcta_national.zip",
       "cb_2023_17_tract_500k.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_17_tract_500k.zip"}
for name, url in SRC.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=300).content)

h = pd.read_csv("data/Hospital_General_Information.csv", dtype=str)
h = h[h.State.isin(["IL", "IN", "IA", "MO", "KY", "WI"])]
tec = pd.read_csv("data/Timely_and_Effective_Care-Hospital.csv", dtype=str)
op18 = tec[(tec["Measure ID"] == "OP_18b") & (tec.Score != "Not Available")]    # median time in the emergency department
REPORTS_ED = set(op18["Facility ID"])
h["has_ed"] = (h["Emergency Services"] == "Yes") | h["Facility ID"].isin(REPORTS_ED)
GENERAL = ~h["Hospital Type"].isin(["Psychiatric", "Childrens"])            # not general emergency departments
HOSP = h[h.has_ed & GENERAL].reset_index(drop=True)
G = Path("data/geocoded.csv")
if not G.exists():                                                          # one request for every address
    batch = HOSP[["Facility ID", "Address", "City/Town", "State", "ZIP Code"]].to_csv(index=False, header=False)
    r = requests.post("https://geocoding.geo.census.gov/geocoder/locations/addressbatch", timeout=600,
                      files={"addressFile": ("hospitals.csv", batch)}, data={"benchmark": "Public_AR_Current"})
    G.write_text(r.text)
geo = pd.read_csv(G, header=None, dtype=str, names=["Facility ID", "input", "match", "exact", "matched", "coords", "tiger", "side"])
geo = geo[geo.match == "Match"]
geo[["lon", "lat"]] = geo.coords.str.split(",", expand=True).astype(float)
HOSP = HOSP.merge(geo[["Facility ID", "lat", "lon"]], on="Facility ID", how="left")
z = pd.read_csv(zipfile.ZipFile("data/gaz_zcta.zip").open("2023_Gaz_zcta_national.txt"), sep="\t", dtype={"GEOID": str})
z.columns = z.columns.str.strip()                                           # the last header carries trailing spaces
zip_pt = z.set_index("GEOID")[["INTPTLAT", "INTPTLONG"]]
miss = HOSP.lat.isna()
HOSP.loc[miss, "lat"] = HOSP.loc[miss, "ZIP Code"].str[:5].map(zip_pt.INTPTLAT)
HOSP.loc[miss, "lon"] = HOSP.loc[miss, "ZIP Code"].str[:5].map(zip_pt.INTPTLONG)
HOSP["how"] = np.where(miss, "ZIP centre", "address")
lost = HOSP[HOSP.lat.isna()]                                                # ZIP codes with no tabulation area
HOSP = HOSP.dropna(subset=["lat"]).reset_index(drop=True)

POP = pd.read_csv("data/CenPop2020_Mean_TR17.txt", dtype={"STATEFP": str, "COUNTYFP": str, "TRACTCE": str}, encoding="utf-8-sig")
POP["GEOID"] = POP.STATEFP + POP.COUNTYFP + POP.TRACTCE
POP = POP[POP.POPULATION > 0].reset_index(drop=True)
TRACTS = gpd.read_file("data/cb_2023_17_tract_500k.zip")
print(f"{len(HOSP)} hospitals with emergency services in IL and neighbours ({(HOSP.State == 'IL').sum()} in Illinois); "
      f"{len(POP):,} populated Illinois tracts")
print("dropped, no address match and no ZIP area:", "; ".join(lost["Facility Name"].str.title() + ", " + lost.State))
710 hospitals with emergency services in IL and neighbours (173 in Illinois); 3,261 populated Illinois tracts
dropped, no address match and no ZIP area: The Medical Center At Franklin, KY; Cox Medical Center Branson, MO; University Of Missouri Health Care, MO

Example 1: how often the nearest hospital is a critical access hospital

Critical access hospitals are small rural hospitals with at most 25 beds:

POP["type"] = HOSP["Hospital Type"].to_numpy()[idx]
share = POP.groupby("type").POPULATION.sum() / w.sum()
print(share.sort_values(ascending=False).map("{:.1%}".format).to_string())
far = POP[POP.km > 20]
print(f"of people more than 20 km away, nearest is a critical access hospital for "
      f"{far[far.type == 'Critical Access Hospitals'].POPULATION.sum() / far.POPULATION.sum():.0%}")
type
Acute Care Hospitals                    90.1%
Critical Access Hospitals                8.7%
Acute Care - Veterans Administration     1.2%
of people more than 20 km away, nearest is a critical access hospital for 34%

Example 2: the counties farthest from care

POP["county"] = POP.COUNTYFP
c = POP.groupby("county").apply(lambda d: pd.Series({"people": d.POPULATION.sum(),
                                                     "median_km": np.median(np.repeat(d.km, d.POPULATION))}), include_groups=False)
names = TRACTS.drop_duplicates("COUNTYFP").set_index("COUNTYFP").NAMELSADCO
print(c.sort_values("median_km", ascending=False).head(5).assign(name=lambda d: d.index.map(names)).round(1).to_string())
         people  median_km             name
county                                     
193     13877.0       32.3     White County
123     11742.0       31.9  Marshall County
041     19740.0       30.6   Douglas County
153      5193.0       29.8   Pulaski County
079      9287.0       29.1    Jasper County

Straight-line distance is a floor

The steps from a hospital list to distances for every tract, with the Illinois numbers. from a hospital list to distanceswhichhospitalsflag or ED times:710geocode625 address, 85 ZIPwhere peoplelive3,261 populationcentresnearest on thesphereKD-tree, chord → kmweight bypeople4.1% over 20 kmIllinois and the five neighbouring states, CMS hospitals with emergency departments spatialworkflow.io
From a hospital list to distances for every tract.

Straight-line distance is a floor, not a travel time. Roads, rivers and bridges make real journeys longer, sometimes much longer where a river has few crossings. Drive times from a road network are the better measure where they matter, and the same three choices apply to them: which facilities, measured from where, and across which boundaries. The straight-line version is still the right first step, because it is quick, needs no routing data, and identifies where the more expensive analysis is needed.

The emergency services flag in the CMS file means a hospital has an emergency department; it says nothing about trauma level, stroke or heart attack capability, or obstetrics. A study of access to a particular kind of care needs a list of the facilities that provide it, and the nearest hospital on this map may not be the one an ambulance takes a stroke patient to.

List coverage, geocoding and ZIP fallbacks

Measuring distance to the nearest hospital: problems and fixes. hospital distance trapsTrapInsteadtrusting the emergency flagcheck ED times: 3 more in Illinoisdropping unmatched addressesZIP centre, flagged: 85 hospitalsstate hospitals onlyadd neighbours: 57 tracts closerstraight line read as travel timea floor: route where it mattersCMS hospitals, Census geocoder, Illinois tracts 2020 spatialworkflow.io
Hospital distance traps.
  • Coverage of the list: CMS lists Medicare-certified hospitals; freestanding emergency departments and some facilities are not in it.
  • Geocoding: check the match rate and the unmatched list; large, well-known hospitals fail too, such as Hines VA Medical Center here.
  • ZIP fallback: a ZIP tabulation area's internal point can be several kilometres from the hospital in rural ZIPs.
  • Population: 2020 centres of population; tracts with no residents are dropped.
  • Distances: the chord-to-arc formula is exact on a sphere; the difference from the ellipsoid is well under 1% at these distances.

FAQ

How do I find the nearest hospital to each census tract in Python?

Put hospital and tract coordinates on the unit sphere, query a scipy cKDTree, and convert the chord distance to kilometres with 2R·asin(c/2).

Where can I get a list of US hospitals with coordinates?

The CMS Hospital General Information file has every Medicare-certified hospital with its address and emergency services flag; geocode the addresses with the Census Bureau's free batch geocoder.

Should I use tract centroids or population centres?

Population centres, published by the Census Bureau for every tract, which place each tract where its residents live.

Why include hospitals in other states?

People near a state line often use hospitals across it; leaving them out overstates distances for border communities.