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.csv with 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) and classified_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.csv exists 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.