Fixing TRI County Names That Do Not Join to County Boundaries

On this page

Problem statement

The Toxics Release Inventory gives every facility's county as a name in capitals, not as a FIPS code. Join those names to a county boundary file and about one facility in twenty finds no match: Saint is written ST, parishes carry the word PARISH, independent cities are marked (CITY), and Connecticut's counties were replaced by planning regions in the boundaries. Even where a name matches, it is sometimes not the county the facility's coordinates fall in. This page measures both problems in the 2024 file and uses the coordinates instead.

Quick answer

Make points from the latitude and longitude and place each facility with a spatial join:

fac = t24.drop_duplicates("TRIFD")[["TRIFD", "FACILITY NAME", "COUNTY", "ST", "LATITUDE", "LONGITUDE"]]
pts = gpd.GeoDataFrame(fac, geometry=gpd.points_from_xy(fac.LONGITUDE, fac.LATITUDE), crs=4269)
j = gpd.sjoin(pts, counties.to_crs(4269)[["GEOID", "NAME", "STUSPS", "geometry"]], predicate="within", how="left")
print(f"facilities: {len(fac):,}; placed in a county by location: {j.GEOID.notna().sum():,}")
facilities: 21,482; placed in a county by location: 21,451

The spatial join places 21,451 of the 21,482 facilities in a 2024 county.

Two bars of facilities placed in a 2024 county: 21,451 by spatial join against 20,333 by county name and state, 94.7%. Two bars of facilities placed in a 2024 county: 21,451 by spatial join against 20,333 by county name and state, 94.7%.
Joining facilities to counties by location and by name.

Why the names fail

1. Join on the name

cn = counties.assign(key=counties.NAME.str.upper() + "|" + counties.STUSPS)
f = fac.assign(key=fac.COUNTY.str.upper().str.strip() + "|" + fac.ST)
hit = f.key.isin(cn.key)
print(f"matched by name and state: {hit.sum():,} of {len(f):,} ({hit.mean():.1%})")
print(f[~hit].COUNTY.value_counts().head(8).to_string())
matched by name and state: 20,333 of 21,482 (94.7%)
COUNTY
NEW HAVEN                  77
HARTFORD                   73
ST LOUIS                   56
ST LOUIS (CITY)            45
ST JOSEPH                  44
FAIRFIELD                  40
CALCASIEU PARISH           31
EAST BATON ROUGE PARISH    27

Joining on name and state instead matches 20,333 facilities, 94.7%. The unmatched names include New Haven, Hartford and Fairfield in Connecticut, ST LOUIS and ST LOUIS (CITY), and parishes written in full.

2. What the unmatched names are

miss = f[~hit]
print("with '(CITY)':", miss.COUNTY.str.contains(r"\(CITY\)").sum(), "| with 'ST.' or 'SAINT':", miss.COUNTY.str.contains(r"^ST\.?\s|SAINT").sum(),
      "| Connecticut:", (miss.ST == "CT").sum(), "| Alaska:", (miss.ST == "AK").sum(), "| outside the states:", miss.ST.isin(["PR", "GU", "VI", "MP", "AS"]).sum())
with '(CITY)': 181 | with 'ST.' or 'SAINT': 262 | Connecticut: 252 | Alaska: 48 | outside the states: 123

181 unmatched names carry (CITY), 262 start with ST or contain SAINT, 252 are in Connecticut and 48 in Alaska, and 123 facilities are outside the states. Each could be fixed by a rule, but every rule leaves exceptions.

Horizontal bars of why county names fail to join: 262 with ST or SAINT, 252 in Connecticut, 181 with (CITY), 123 outside the states and 48 in Alaska. Horizontal bars of why county names fail to join: 262 with ST or SAINT, 252 in Connecticut, 181 with (CITY), 123 outside the states and 48 in Alaska.
Why TRI county names fail to join.

3. Location and name disagree

both = j[j.GEOID.notna()].assign(key=lambda d: d.COUNTY.str.upper().str.strip() + "|" + d.ST)
agree = both.key == both.NAME.str.upper() + "|" + both.STUSPS
print(f"name matches the county the point falls in: {agree.mean():.1%}")
print(both[~agree & both.key.isin(cn.key)][["FACILITY NAME", "COUNTY", "NAME", "STUSPS"]].head(4).to_string(index=False))
name matches the county the point falls in: 92.9%
                                   FACILITY NAME      COUNTY      NAME STUSPS
                 CHEVRON PHILLIPS CHEMICAL CO LP      HARRIS  Chambers     TX
    US ARMY NATIONAL GUARD CAMP ATTERBURY RANGES BARTHOLOMEW   Johnson     IN
SYNTHOMER ADHESIVE TEHCNOLOGIES EASTOFLEX & EAST    HARRISON     Gregg     TX
                          BUCKEYE FIRE EQUIPMENT      GASTON Cleveland     NC

Where both work, the name agrees with the county the point falls in for 92.9% of facilities. Some disagreements are a plant near a county line reported under the neighbouring county, such as a Chevron Phillips plant listed in Harris County whose coordinates are in Chambers County, Texas.

Points near borders and a rule

The data used on this page

The US EPA's Toxics Release Inventory basic data files for 2014 and 2024, public domain, downloaded on 16 October 2026 and saved on this site, because EPA revises past years when facilities resubmit. With them come the Census Bureau's Vintage 2025 county population estimates and 2024 county boundaries.

from pathlib import Path
import re
import zipfile

import geopandas as gpd
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
# EPA Toxics Release Inventory basic data files, reporting years 2014 and 2024, all states, xz-compressed and
# otherwise unchanged; Census Vintage 2025 county estimates and 2024 cartographic boundary counties.
# Downloaded 16 October 2026 and saved here, because EPA revises past years.
# Live: https://data.epa.gov/efservice/downloads/tri/mv_tri_basic_download/2024_US/csv (and 2014_US)
ARCHIVE = "epa-tri-2014-2024-2026-10-16.zip"
if not Path(f"data/{ARCHIVE}").exists():
    Path(f"data/{ARCHIVE}").write_bytes(requests.get(f"https://data.spatialworkflow.io/{ARCHIVE}", timeout=600).content)
Z = zipfile.ZipFile(f"data/{ARCHIVE}")


def tri(year):
    """One year's TRI file: a row per facility and chemical, columns numbered '1. YEAR' to '122. ...'."""
    d = pd.read_csv(Z.open(f"tri_{year}_us.csv.xz"), compression="xz", dtype=str, low_memory=False)
    d.columns = [re.sub(r"^\d+\.\s*", "", c) for c in d.columns]
    amounts = list(d.columns[d.columns.get_loc("5.1 - FUGITIVE AIR"):d.columns.get_loc("8.8 - ONE-TIME RELEASE") + 1])
    d[amounts + ["LATITUDE", "LONGITUDE"]] = d[amounts + ["LATITUDE", "LONGITUDE"]].apply(pd.to_numeric, errors="coerce")
    return d


def pounds(d, col="TOTAL RELEASES"):
    """A quantity in pounds: dioxin and dioxin-like compounds are reported in grams."""
    return d[col].where(d["UNIT OF MEASURE"] == "Pounds", d[col] / 453.59237)


t24 = tri(2024)
pop = pd.read_csv(Z.open("co-est2025-alldata.csv"), encoding="latin-1", dtype={"STATE": str, "COUNTY": str})
pop = pop[pop.SUMLEV == 50].assign(GEOID=lambda p: p.STATE + p.COUNTY)
Path("data/cb_2024_us_county_20m.zip").write_bytes(Z.read("cb_2024_us_county_20m.zip"))
counties = gpd.read_file("data/cb_2024_us_county_20m.zip")
print(f"TRI 2024: {len(t24):,} facility-chemical rows from {t24.TRIFD.nunique():,} facilities; {pounds(t24).sum() / 1e9:.2f} billion lb released")
TRI 2024: 77,295 facility-chemical rows from 21,482 facilities; 3.29 billion lb released

Example 1: the rule

j["county"] = j.GEOID
print(f"facilities with a county by location: {j.county.notna().mean():.2%}; left over: {j[j.county.isna()].ST.value_counts().head(4).to_dict()}")
facilities with a county by location: 99.86%; left over: {'GU': 9, 'VI': 5, 'MP': 4, 'HI': 2}

The spatial join places 99.86% of facilities. What is left are 9 in Guam, 5 in the Virgin Islands, 4 in the Northern Marianas, which the boundary file does not include, and 2 Hawaii facilities on the coast just outside the generalised county outlines.

Example 2: facilities per county

per = j.groupby(["GEOID", "NAME", "STUSPS"]).size().sort_values(ascending=False)
print(per.head(5).to_string())
GEOID  NAME         STUSPS
48201  Harris       TX        358
06037  Los Angeles  CA        307
17031  Cook         IL        290
04013  Maricopa     AZ        194
39035  Cuyahoga     OH        126
Map of the lower 48 with grey dots for facilities whose county name matches and red dots where it differs from the county they sit in, clustered in Louisiana parishes, Connecticut, Virginia and around St. Louis. Map of the lower 48 with grey dots for facilities whose county name matches and red dots where it differs from the county they sit in, clustered in Louisiana parishes, Connecticut, Virginia and around St. Louis.
Facilities whose county name differs from their location.

Harris County, Texas has the most facilities, 358, then Los Angeles, Cook, Maricopa and Cuyahoga counties.