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.

Paired bars on a log scale for the eight largest dioxin reporters, the amount summed as if pounds far longer than the amount converted to pounds. Paired bars on a log scale for the eight largest dioxin reporters, the amount summed as if pounds far longer than the amount converted to pounds.
The largest dioxin reporters, before and after converting grams.

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.

A rank scale of 577 chemicals with dioxins marked at 271st when grams are summed as pounds and 448th when converted. A rank scale of 577 chemicals with dioxins marked at 271st when grams are summed as pounds and 448th when converted.
Where dioxins rank, with and without the unit fixed.

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
Horizontal bars of dioxin releases by sector in grams: chemicals 34,441.3, hazardous waste 24,196.3, then primary metals, paper and electric utilities. Horizontal bars of dioxin releases by sector in grams: chemicals 34,441.3, hazardous waste 24,196.3, then primary metals, paper and electric utilities.
Dioxin releases by sector, in grams.

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.