Why Is My Crash Death Count Bigger Than the Official Total?
On this page
Problem statement
You join the FARS crash file to the vehicle file to filter by vehicle type, sum the deaths, and get a number far above NHTSA's published total. Or you count deaths in crashes involving a large truck and get more than there were. Crash data is hierarchical: one crash, several vehicles, several people. A crash-level number such as FATALS copied onto every vehicle row is summed once per vehicle, and a join to people multiplies it again. The fix is to decide at which level each number lives and to filter at that level, without changing the grain of the table you sum.
This page measures the inflation on FARS 2023 when crash deaths are summed after joining to vehicles and to people, reconciles the three death counts the files offer, and computes deaths in large-truck crashes the right and the wrong way.
Quick answer
Filter crashes by a condition on their vehicles with a set of case numbers, never by summing a crash-level column after a join:
trucks = V[V.BODY_TYP.between(60, 79)] # medium and heavy trucks
right = A[A.ST_CASE.isin(trucks.ST_CASE)].FATALS.sum()
wrong = A.merge(trucks, on="ST_CASE").FATALS.sum()
print(f"deaths in crashes involving a large truck: {right:,}; summed after the join: {wrong:,} (+{wrong / right - 1:.0%})")
print(f"crashes with more than one large truck: {(trucks.groupby('ST_CASE').size() > 1).sum():,}")
deaths in crashes involving a large truck: 5,583; summed after the join: 6,227 (+12%)
crashes with more than one large truck: 391
5,583 people died in crashes involving a medium or heavy truck in 2023. Summed after the join, the count is 6,227, 12% too high, because 391 crashes involved more than one truck and their deaths were added once per truck.
Measure join inflation, reconcile three counts, choose the grain
1. Measure the inflation from a join
Sum FATALS on the crash file, after joining to vehicles and after joining to people:
print(f"crash file: {A.FATALS.sum():,}")
print(f"after joining vehicles: {A.merge(V, on='ST_CASE').FATALS.sum():,}")
print(f"after joining people: {A.merge(P, on='ST_CASE').FATALS.sum():,}")
print(f"rows: {len(A):,} crashes, {len(V):,} vehicles, {len(P):,} people")
crash file: 41,025
after joining vehicles: 64,946
after joining people: 107,144
rows: 37,769 crashes, 58,508 vehicles, 92,768 people
Joined to vehicles, the 41,025 deaths become 64,946; joined to people, 107,144, more than two and a half times the true total. Every row repeats its crash's FATALS.
2. Reconcile the three death counts
Deaths appear at all three levels: FATALS on the crash, DEATHS on each vehicle for its occupants, and INJ_SEV 4 on each person:
killed = P.INJ_SEV == 4
print(f"FATALS on crashes: {A.FATALS.sum():,}; people with INJ_SEV 4: {killed.sum():,}")
non_occupants = killed & (P.VEH_NO == 0)
parked = killed & (P.PER_TYP == 3) # occupants of parked vehicles, numbered apart from vehicle.csv
print(f"DEATHS on vehicles in transport: {V.DEATHS.sum():,}; non-occupants (VEH_NO 0): {non_occupants.sum():,}; occupants of parked vehicles: {parked.sum()}")
print(f"together: {V.DEATHS.sum() + non_occupants.sum() + parked.sum():,}")
print(P[non_occupants].PER_TYP.map({5: "pedestrian", 6: "cyclist"}).fillna("other non-occupant").value_counts().to_string())
FATALS on crashes: 41,025; people with INJ_SEV 4: 41,025
DEATHS on vehicles in transport: 32,140; non-occupants (VEH_NO 0): 8,821; occupants of parked vehicles: 64
together: 41,025
PER_TYP
pedestrian 7367
cyclist 1163
other non-occupant 291
The person and crash files agree at 41,025. Vehicle DEATHS covers only occupants of vehicles in transport, 32,140; adding the 8,821 people killed outside any vehicle, mostly pedestrians and cyclists, and the 64 killed in parked vehicles gives exactly 41,025.
3. Choose the grain for the question
Each question has a natural level. Count at it:
occupants = P[killed & (P.VEH_NO > 0)].merge(V[["ST_CASE", "VEH_NO", "BODY_TYP"]], on=["ST_CASE", "VEH_NO"])
print(f"people killed inside a large truck: {occupants.BODY_TYP.between(60, 79).sum():,}")
print(f"people killed in crashes involving a large truck: {right:,}")
print(f"large-truck crashes: {A.ST_CASE.isin(trucks.ST_CASE).sum():,}")
people killed inside a large truck: 1,002
people killed in crashes involving a large truck: 5,583
large-truck crashes: 4,999
Three different numbers answer three different questions: 1,002 people died inside large trucks, 5,583 died in 4,999 crashes involving them.
A check that catches double counting
The data used on this page
NHTSA's FARS 2023 national file: accident.csv, vehicle.csv and person.csv, linked by ST_CASE and, for vehicles and people, VEH_NO.
import zipfile
from pathlib import Path
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
F = Path("data/FARS2023NationalCSV.zip")
if not F.exists():
F.write_bytes(requests.get("https://static.nhtsa.gov/nhtsa/downloads/FARS/2023/National/FARS2023NationalCSV.zip", timeout=600).content)
Z = zipfile.ZipFile(F)
A = pd.read_csv(Z.open("FARS2023NationalCSV/accident.csv"), encoding="latin1", low_memory=False, usecols=["ST_CASE", "STATE", "FATALS"])
V = pd.read_csv(Z.open("FARS2023NationalCSV/vehicle.csv"), encoding="latin1", low_memory=False, usecols=["ST_CASE", "VEH_NO", "BODY_TYP", "DEATHS"])
P = pd.read_csv(Z.open("FARS2023NationalCSV/person.csv"), encoding="latin1", low_memory=False,
usecols=["ST_CASE", "VEH_NO", "PER_NO", "PER_TYP", "INJ_SEV"])
print(f"{len(A):,} crashes, {len(V):,} vehicles, {len(P):,} people")
37,769 crashes, 58,508 vehicles, 92,768 people
Example 1: a check that catches it
Before summing, assert that the table has one row per unit of the number being summed:
def safe_sum(df, col, key):
if df.duplicated(key).any():
raise ValueError(f"{col} is summed over {df.duplicated(key).sum():,} repeated {key} rows")
return df[col].sum()
print(f"crash file: {safe_sum(A, 'FATALS', 'ST_CASE'):,}")
try:
safe_sum(A.merge(V, on="ST_CASE"), "FATALS", "ST_CASE")
except ValueError as e:
print("joined to vehicles:", e)
crash file: 41,025
joined to vehicles: FATALS is summed over 20,739 repeated ST_CASE rows
A small guard turns the silent error into an exception: the joined table repeats 20,739 case numbers.
Each fact stored at its own level
Relational crash data stores each fact at the level where it belongs: the time and place of the crash once, each vehicle's type once per vehicle, each person's injury once per person. A join copies the higher level's facts onto every row of the lower level. That is what makes filters such as "crashes involving a truck" easy to write and easy to get wrong: after the join, FATALS appears once per vehicle, and a crash with two trucks contributes its deaths twice.
The cure is to keep the grain explicit. Filter crashes with a set of case numbers derived from the vehicle or person table, or aggregate the lower table up to the crash first and then join one row per crash. When a number exists at several levels, as deaths do here, decide which one answers the question: deaths in crashes involving a truck is a crash-level sum; deaths of truck occupants is a person-level count. The three FARS death counts reconcile exactly once the people outside any vehicle in transport are added back: pedestrians, cyclists and other non-occupants, and the occupants of parked vehicles, which are numbered separately from the vehicle file.
STATS19's same shape and one-to-many filters
- STATS19 has the same shape: collisions, vehicles and casualties; summing
number_of_casualtiesafter joining vehicles double-counts in the same way. - One-to-many filters:
isinon case numbers is safe;mergefollowed bydrop_duplicates("ST_CASE")also works if done before summing. - Per-vehicle questions: "drivers killed by vehicle type" needs the person table joined to the vehicle table on both
ST_CASEandVEH_NO. - Body type codes: 60β79 are medium and heavy trucks and their combinations in FARS; buses, pickups and vans have other codes.
- Validate: check that the crash-level total matches the published figure before any join, and again after.
Internal links
- How to map fatal road crashes in the US with FARS in Python β the three FARS files
- How to analyse cyclist casualties from STATS19 in Python β joining STATS19's three files
- How to analyse speeding-related crashes with FARS in Python β rolling vehicles up to crashes
FAQ
Why do I get more road deaths than NHTSA reports?
Usually because a crash-level column, FATALS, was summed after joining to vehicles or people, which repeats it once per row; in FARS 2023 that turns 41,025 deaths into 64,946 or 107,144.
How do I count deaths in crashes involving trucks?
Collect the case numbers of crashes with a truck from the vehicle file and sum FATALS over those crashes: 5,583 in 2023, not the 6,227 a join gives.
What is the difference between FATALS, DEATHS and INJ_SEV in FARS?
FATALS counts deaths per crash, DEATHS counts deaths among a vehicle's occupants, and INJ_SEV 4 marks each person killed; they agree once non-occupants and occupants of parked vehicles are included.
How can I catch double counting in pandas?
Check that the key of the level you are summing is unique before calling sum, and raise if it is not.