Fixing TRI Totals That Mix Pounds and Grams
On this page
Problem statement
Sum any amount column of a Toxics Release Inventory file and the result is slightly wrong. The file has a UNIT OF MEASURE column, and while almost every row is in pounds, one chemical category, dioxin and dioxin-like compounds, is reported in grams because the amounts are tiny and the toxicity enormous. A plain sum adds grams to pounds. Nationally the error is small, but for the facilities that report dioxins it is a factor of 454, and in any ranking of chemicals or facilities by amount it moves dioxins far from where they belong. This page finds the gram rows in the 2024 file and converts them.
Quick answer
Check the units, then compare a plain sum with one that converts grams to pounds (453.59237 g to the pound):
print(t24["UNIT OF MEASURE"].value_counts().to_string())
naive, right = t24["TOTAL RELEASES"].sum(), pounds(t24).sum()
print(f"summed as is: {naive:,.0f}; grams converted: {right:,.0f}; difference {naive - right:,.0f}")
print("grams per pound:", 453.59237)
UNIT OF MEASURE
Pounds 76574
Grams 721
summed as is: 3,290,590,847; grams converted: 3,290,529,168; difference 61,678
grams per pound: 453.59237
76,574 rows are in pounds and 721 in grams. A plain sum of total releases overstates the national figure by 61,678 lb, about two thousandths of a percent.
One chemical category, in grams
1. Which rows
g = t24[t24["UNIT OF MEASURE"] == "Grams"]
print(g.CHEMICAL.value_counts().to_string())
print(f"dioxin rows: {len(g)}, at {g.TRIFD.nunique()} facilities; total {g['TOTAL RELEASES'].sum():,.1f} g = {g['TOTAL RELEASES'].sum() / 453.59237:,.2f} lb")
CHEMICAL
Dioxin and dioxin-like compounds 721
dioxin rows: 721, at 715 facilities; total 61,814.7 g = 136.28 lb
All 721 gram rows are dioxin and dioxin-like compounds, at 715 facilities: 61,814.7 grams in total, which is 136.28 lb.
2. Where it matters
fac = t24.assign(naive=t24["TOTAL RELEASES"], lb=pounds(t24)).groupby("TRIFD").agg(name=("FACILITY NAME", "first"), naive=("naive", "sum"), lb=("lb", "sum"))
fac["error"] = fac.naive / fac.lb.replace(0, np.nan) - 1
print(fac[fac.naive > fac.lb].nlargest(4, "error")[["name", "naive", "lb", "error"]].round(2).to_string(index=False))
name naive lb error
SIERRA PACIFIC INDUSTRIES ANDERSON DIV 0.32 0.0 452.59
SIERRA PACIFIC INDUSTRIES BURNEY DIV 0.30 0.0 452.59
MARCAL MANUFACTURING LLC 2.02 0.0 452.59
TELESCOPE CASUAL FURNITURE INC. 0.14 0.0 452.59
For a facility whose only reported releases are dioxins, a plain sum is 452.59 times too large, 453.59 times the true figure. At the sawmills and paper mills at the top of this list the error is the whole number.
3. Rankings by chemical
c = t24.assign(naive=t24["TOTAL RELEASES"]).groupby("CHEMICAL")["naive"].sum().rank(ascending=False)
c2 = t24.assign(lb=pounds(t24)).groupby("CHEMICAL").lb.sum().rank(ascending=False)
d = "Dioxin and dioxin-like compounds"
print(f"dioxin rank among {len(c)} chemicals: summed as is {c[d]:.0f}, in pounds {c2[d]:.0f}")
dioxin rank among 577 chemicals: summed as is 271, in pounds 448
Ranked by a plain sum, dioxins come 271st of 577 chemicals; in pounds they are 448th.
Toxic equivalents, and every amount column
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: every column, not only the total
amounts = [c for c in t24.columns if t24[c].dtype.kind == "f" and c not in ("LATITUDE", "LONGITUDE", "8.9 - PRODUCTION RATIO")]
affected = [c for c in amounts if t24.loc[t24["UNIT OF MEASURE"] == "Grams", c].fillna(0).ne(0).any()]
print(f"amount columns: {len(amounts)}; columns where grams rows hold numbers: {len(affected)}")
print(affected[:6])
amount columns: 70; columns where grams rows hold numbers: 51
['5.1 - FUGITIVE AIR', '5.2 - STACK AIR', '5.3 - WATER', '5.4.1 - UNDERGROUND CL I', '5.4.2 - UNDERGROUND C II-V', '5.5.1A - RCRA C LANDFILL']
51 of the 70 amount columns hold numbers on the gram rows, so the conversion has to be applied to every column used, not only to TOTAL RELEASES. The pounds() helper in the setup does it for any column.
Example 2: dioxin by sector, in grams
print(g.groupby("INDUSTRY SECTOR")["TOTAL RELEASES"].sum().nlargest(5).round(1).to_string())
INDUSTRY SECTOR
Chemicals 34441.3
Hazardous Waste 24196.3
Primary Metals 1576.2
Paper 920.6
Electric Utilities 381.2
Chemical plants reported 34,441.3 g of dioxins and hazardous-waste facilities 24,196.3 g; together they are almost all of the total. For dioxins, compare grams with grams, or convert to toxic equivalents with the congener data EPA publishes separately.
Internal links
- Read the TRI files β the
pounds()helper every page uses - Release totals explained β sums that need one unit
- Carcinogen releases β where small amounts matter
- Form A β the other way an amount column misleads