How to Track Weekly Changes in FDA Enforcement Reports
FDA publishes its Enforcement Report weekly. New recalls appear, but existing records also change: a status moves to Terminated, a classification is added, a product description or distribution pattern is corrected. If you're a regulatory analyst, a QA team watching competitors' product codes, or a consultant writing a monthly client brief, you need both: what's new, and what changed.
openFDA gives you the current version of each record. It doesn't keep the old version. This guide shows how to capture changes yourself.
What changes in an enforcement record
Fields worth watching (drug, device and food enforcement share them):
| Field | Why it changes |
|---|---|
status | Ongoing, Completed, Terminated, etc. |
classification | Set when FDA classifies the recall |
report_date | The Enforcement Report week the record appears in |
termination_date | Filled in when FDA terminates the recall |
product_quantity, distribution_pattern | Corrections and updates |
reason_for_recall, product_description | Occasional text corrections |
voluntary_mandated, initial_firm_notification | Rare corrections |
One important limit: openFDA says enforcement data shouldn't be used to monitor a recall's lifecycle or to issue public alerts. A change log tells you how published records changed between your snapshots. It isn't an official status history. Use it for analysis and reporting, and cite the FDA record itself for current status.
Step 1: Weekly snapshot
Use the bulk files, not the paged API, so each snapshot is complete:
import requests, zipfile, io, json, pandas as pd, datetime as dt
def snapshot(kind): # "drug", "device", "food"
m = requests.get("https://api.fda.gov/download.json").json()
frames = []
for p in m["results"][kind]["enforcement"]["partitions"]:
z = zipfile.ZipFile(io.BytesIO(requests.get(p["file"]).content))
frames.append(pd.json_normalize(
json.load(z.open(z.namelist()[0]))["results"]))
df = pd.concat(frames)
df["product_type"] = kind
return df
today = dt.date.today().isoformat()
snap = pd.concat([snapshot(k) for k in ["drug", "device", "food"]])
snap = snap.drop_duplicates("recall_number", keep="last")
snap.to_parquet(f"enf_{today}.parquet")
Schedule it with cron, Windows Task Scheduler or a GitHub Actions workflow on a weekly cron. Run it a day after FDA's weekly update so the export has landed.
Step 2: Diff
WATCH = ["status", "classification", "termination_date",
"product_quantity", "distribution_pattern",
"reason_for_recall", "product_description"]
def diff(old_path, new_path, week):
a = pd.read_parquet(old_path).set_index("recall_number")
b = pd.read_parquet(new_path).set_index("recall_number")
out = []
for r in b.index.difference(a.index):
out.append({"recall_number": r, "change": "added", "week": week})
for r in a.index.difference(b.index):
out.append({"recall_number": r, "change": "removed", "week": week})
common = a.index.intersection(b.index)
for col in WATCH:
old = a.loc[common, col].fillna("")
new = b.loc[common, col].fillna("")
for r in common[old.ne(new)]:
out.append({"recall_number": r, "change": "modified",
"field": col, "old": a.at[r, col],
"new": b.at[r, col], "week": week})
return pd.DataFrame(out)
Normalise before comparing (strip whitespace, consistent case for status values), or you'll log noise.
Step 3: Report
With a changes table, weekly reporting is a group-by:
changes.groupby(["week", "change"]).size().unstack(fill_value=0)
# Recalls newly classified Class I this week, for device product codes you track
my_codes = {"DXN", "FRN", "QAS"}
For product-code filtering on device enforcement, join to device recall records or 510(k) data on firm and product, since enforcement records don't always carry a product code directly. Expect some manual review.
Simple outputs that clients find useful:
- New records this week, by classification and product type.
- Records that moved to Terminated.
- Class changes.
- A firm watchlist: any change for firms in a list.
Step 4: Keep it running
The maintenance costs are real:
- FDA occasionally changes field names or adds fields; your diff breaks quietly.
- Missed weeks leave gaps you can't backfill, because the old versions are gone.
- Storage of full weekly snapshots grows steadily.
Or use a maintained feed
The Apex US Regulatory Events Feed runs the same snapshot-and-diff idea every week across drug, device and food enforcement reports, 510(k)/PMA decisions and drug shortages. It publishes a weekly_changes table (record key, table, change type, changed columns, week) plus the cleaned current tables. You can read it as CSV, or live through Delta Sharing in pandas, Spark or Power BI:
import delta_sharing
changes = delta_sharing.load_as_pandas("config.share#us_regulatory_feed.gold.weekly_changes")
Our change history starts when our snapshots started; we state that date and don't backfill guesses. Free samples: https://apex-feed.pages.dev/#samples. Full feed: https://apex-feed.pages.dev ($50 one-time snapshot, $39/month, $390/year).
If you have the time to maintain the script above, it works and it's free.
Source: U.S. FDA via openFDA. Apex is not affiliated with or endorsed by the U.S. Food and Drug Administration. This is a change history of published records; it is not a recall-status tracker or a public alert service. Not medical or legal advice.