When a pet food company starts a recall, the record most tools are built on isn't out yet. FDA's classified recall record (the one with the Class I/II/III rating, the exact lot codes and the barcodes) only appears once FDA has reviewed the recall and classified it.
So how long does that take? I counted, for every dog and cat food recall FDA has classified since its Enforcement Reports went online in 2012:
| FDA classified it | Recalls | Median days from start to classification | Classified within 30 days |
|---|---|---|---|
| 2012–2019 | 158 | 140 | 3% |
| 2020 onward | 108 | 30 | 51% |
That's about 4.7 times faster. Below is the script that produces every number in this post, plus the check I ran before believing it. A drop like this is exactly what a common data bug looks like.
The data
I run PerBowl, which traces US dog and cat foods back to the companies and plants that make them. FDA publishes recalls through its Enforcement Report search, but there's no bulk download filtered to pet food. So I built one and released it as an open dataset under CC BY 4.0:
- Of the 3,660 veterinary-category product rows in FDA's Enforcement Reports, I kept 1,232 dog and cat food products. The other 2,428 were animal drugs and devices, livestock feed, food for other animals, and similar. Every dropped row is in
excluded.csvwith the reason it was dropped, so the filter can be audited. - Those 1,232 products group into 266 recall events, one row each in
recalls.csv. - Each event has
initiated_date(the firm started the recall) andclassified_date(FDA classified it). Both are the earliest date across the event's products.
It's on GitHub, Zenodo and Kaggle. Every release is tagged, so the script below pins the version these numbers came from.
The script
Standard library only. Copy it and run it with python3:
import csv, io, statistics, urllib.request from datetime import date VERSION = "v2026-10-09" # pinned so the numbers reproduce; "main" = latest release URL = f"https://raw.githubusercontent.com/perbowl/pet-food-recalls/{VERSION}/recalls.csv" rows = list(csv.DictReader(io.TextIOWrapper(urllib.request.urlopen(URL), encoding="utf-8"))) def days(a, b): return (date.fromisoformat(b) - date.fromisoformat(a)).days def median(v): return round(statistics.median(v)) recalls = [] for r in rows: if r["initiated_date"] and r["classified_date"]: r["lag"] = days(r["initiated_date"], r["classified_date"]) recalls.append(r) before = [r["lag"] for r in recalls if r["classified_date"] < "2020-01-01"] since = [r["lag"] for r in recalls if r["classified_date"] >= "2020-01-01"] share = lambda v, d: round(100 * sum(x <= d for x in v) / len(v)) print(f"recalls with both dates: {len(recalls)} of {len(rows)}") print(f"classified 2012-2019: n={len(before)} median={median(before)} days, <=30d {share(before, 30)}%") print(f"classified 2020 on: n={len(since)} median={median(since)} days, <=30d {share(since, 30)}%, <=60d {share(since, 60)}%") print(f"ratio: {median(before) / median(since):.1f}x") for c in ("Class I", "Class II", "Class III"): b = [r["lag"] for r in recalls if r["fda_class"] == c and r["classified_date"] < "2020-01-01"] s = [r["lag"] for r in recalls if r["fda_class"] == c and r["classified_date"] >= "2020-01-01"] print(f"{c:9} before {median(b)} (n={len(b)}) since {median(s)} (n={len(s)})") years = {} for r in recalls: years.setdefault(r["classified_date"][:4], []).append(r["lag"]) for y in sorted(years): print(f"{y} {len(years[y]):3} {median(years[y]):4}") # The censoring check (see below): group by the year the FIRM STARTED the recall. settled = [r["lag"] for r in recalls if "2020-01-01" <= r["initiated_date"] < "2024-01-01"] print(f"started 2020-2023: n={len(settled)} median={median(settled)} days") early = [r["lag"] for r in recalls if "2012-01-01" <= r["initiated_date"] < "2016-01-01"] print(f"started 2012-2015: n={len(early)} median={median(early)} days") All 266 recalls have both dates, so nothing gets dropped. Since 2020, 86% were classified within 60 days.
It holds for every class
The obvious objection is that the mix of recalls changed. Fewer serious recalls would mean less review work and a faster median. But the drop shows up within each class:
| 2012–2019 | 2020 onward | |
|---|---|---|
| Class I (reasonable chance of serious harm) | 154 days (n=116) | 32 days (n=84) |
| Class II | 118 days (n=28) | 22 days (n=20) |
| Class III | 64 days (n=14) | 29 days (n=4) |
Before 2020, the most serious recalls waited the longest. Class III has too few rows since 2020 to mean much on its own.
The trap: you can't see the slow ones yet
This is the part worth taking away, even if you never touch pet food data.
The dataset only contains recalls FDA has already classified. A recall that started in spring 2026 and is still waiting isn't in it at all. Recent years are therefore missing their slowest cases, and the median for "2020 onward" gets pulled down by recalls that simply haven't finished yet. It's right-censoring, and it can make any process look as if it's speeding up.
The fix is to choose a group of recalls that have all had time to finish. Group them by the year the firm started the recall, not the year FDA classified it, and keep only starts from 2020 to 2023. Every one of those has had nearly three years or more to get classified, so if slow ones were still pending, they would be missing from this group and would show up later as very long lags.
started 2020-2023: n=56 median=28 days 28 days, which matches the 30-day median for all recalls since 2020. So the drop isn't caused by unfinished recalls. For comparison, recalls started in 2012–2015 had a median of 158 days.
The year-by-year view is messier
| Year classified | Recalls | Median days |
|---|---|---|
| 2012 | 11 | 74 |
| 2013 | 28 | 138 |
| 2014 | 10 | 106 |
| 2015 | 10 | 358 |
| 2016 | 41 | 232 |
| 2017 | 12 | 101 |
| 2018 | 25 | 73 |
| 2019 | 21 | 135 |
| 2020 | 19 | 29 |
| 2021 | 18 | 26 |
| 2022 | 10 | 42 |
| 2023 | 13 | 28 |
| 2024 | 11 | 35 |
| 2025 | 15 | 37 |
| 2026 | 22 | 28 |
Before 2020 the medians jump around, and some years have only 10 recalls. From 2020 on, every year sits between 26 and 42. So it wasn't a gradual improvement. The numbers changed sharply around 2020 and have stayed low since.
Why? The data can't say. In December 2017, an HHS Inspector General audit (A-01-16-01502) found that FDA "could not always ensure that firms initiated recalls promptly." The same month, FDA's commissioner announced a new senior team for difficult recalls. That fits the timing loosely: 2018 was quicker, but 2019 was slow again. I've put the details and sources in the full write-up. I'd treat the cause as an open question.
Why it matters if you build on recall data
A median of 30 days is still a month. Any alert, feed or "is my product recalled?" lookup built only on classified records shows a recall weeks after it started. The company's own announcement usually comes much earlier. My last post covers getting those same-day announcements from one FDA JSON file, and why 21 of the 48 recalls FDA classified from 2024 onward never appeared as announcements at all. You need both sources.
Caveats
- The dates are the earliest across a recall's products. A recall that FDA classifies in stages counts from its first classification.
- "Dog and cat food" comes from a keyword filter on FDA's product descriptions. It's tuned by hand, and
excluded.csvexists so you can check every decision it made. - These are US FDA recalls only. Recalls in other countries aren't included.
Get the data
- Dataset page: perbowl.com/recalls/dataset. It covers files, columns and how the dataset is built.
- Cite this version: PerBowl (2026), US Dog and Cat Food Recalls (FDA, 2012 onward), version 2026-10-09, doi:10.5281/zenodo.23255874. The concept DOI 10.5281/zenodo.23255873 always points to the latest release.
- It's updated monthly. If you find a recall that's misclassified or missing, open an issue on GitHub. Corrections go into the next release.
