DEV Community

NexGenData
NexGenData

Posted on Originally published at thenextgennexus.com

Track FDA drug recall changes with openFDA and 40 lines of Python

The FDA's drug recall database has a quirk that trips up almost everyone who builds on it: a recall record changes after it's published, and nothing tells you it changed.

A recall shows up as Ongoing. Weeks later the same record, under the same recall number, flips to Completed or Terminated. Sometimes the classification changes, or the quantity gets corrected. openFDA always serves the current version of each record, and it keeps no change history. If you want to know what changed since last week, you have to keep your own copy and compare.

This post shows how to do that in about 40 lines of standard-library Python, using a real case: Baxter's IV-bag recalls from August and September 2026.

The data: openFDA's drug enforcement endpoint

https://api.fda.gov/drug/enforcement.json holds every drug recall the FDA has published in its weekly Enforcement Reports since 2004. As of its September 23, 2026 update it held 17,988 records:

Status Records
Terminated 14,806
Ongoing 2,745
Completed 437

No API key is needed for light use: 240 requests a minute and 1,000 a day per IP address. A free key raises the daily limit to 120,000.

Queries use a Lucene-style search parameter. This pulls every 2026 recall from one company:

recalling_firm:"Baxter" AND report_date:[20260101 TO 20261231]
Enter fullscreen mode Exit fullscreen mode

Dates are YYYYMMDD strings, and limit maxes out at 1,000 per call, so you page with skip.

The script

Fetch everything matching the query, key it by recall_number, compare against the last snapshot, and print what's new or changed:

import json, sys, urllib.parse, urllib.request
from pathlib import Path

API = "https://api.fda.gov/drug/enforcement.json"
QUERY = 'recalling_firm:"Baxter" AND report_date:[20260101 TO 20261231]'
SNAPSHOT = Path("recalls.json")
WATCHED = ["status", "classification", "termination_date", "product_quantity"]

def fetch_all(query):
    records, skip = [], 0
    while True:
        url = f"{API}?search={urllib.parse.quote(query)}&limit=1000&skip={skip}"
        try:
            with urllib.request.urlopen(url) as r:
                page = json.load(r)
        except urllib.error.HTTPError as e:
            if e.code == 404:          # openFDA returns 404 when nothing matches
                break
            raise
        records += page["results"]
        skip += 1000
        if skip >= page["meta"]["results"]["total"]:
            break
    return {r["recall_number"]: r for r in records}

def diff(old, new):
    for num, rec in sorted(new.items()):
        if num not in old:
            print(f"NEW      {num}  {rec['classification']:<9} {rec['status']:<10} {rec['product_description'][:60]}")
            continue
        for field in WATCHED:
            before, after = old[num].get(field), rec.get(field)
            if before != after:
                print(f"CHANGED  {num}  {field}: {before!r} -> {after!r}")

current = fetch_all(QUERY)
previous = json.loads(SNAPSHOT.read_text()) if SNAPSHOT.exists() else {}
diff(previous, current)
SNAPSHOT.write_text(json.dumps(current))
print(f"{len(current)} recalls tracked", file=sys.stderr)
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  • openFDA returns HTTP 404 when a search matches nothing. It isn't an error, it just means zero results. Without that check, a quiet query crashes your job.
  • Key on recall_number, not on position. Records don't come back in a stable order, and a record's content changes while its number stays the same. That's exactly what you're trying to catch.

Running it

The first run has no snapshot, so everything is "new." On September 29, 2026 it found 21 Baxter drug recalls for 2026:

NEW      D-0787-2026  Class I   Ongoing    Cefazolin in Dextrose, Injection, USP, 2g / 100mL (20mg / mL
NEW      D-0805-2026  Class II  Ongoing    Vancomycin Injection, USP in 5% Dextrose, 1 g per 200mL (5 m
...
NEW      D-0849-2026  Class I   Ongoing    0.9% Sodium Chloride Injection USP 500 mL, VIAFLEX Plastic C
NEW      D-0850-2026  Class I   Ongoing    Dextrose Injection, USP, 70 %, 2000 mL bags, Rx Only, Baxter
21 recalls tracked
Enter fullscreen mode Exit fullscreen mode

(D-0849 is the saline recall for fiberglass particles, and D-0850 is the dextrose recall for stainless-steel particles. The whole list is broken down on our blog.)

After that, each run prints only the differences. To test the diff, I edited one saved record's status and deleted another, then ran it again:

CHANGED  D-0296-2026  status: 'Terminated' -> 'Ongoing'
NEW      D-0850-2026  Class I   Ongoing    Dextrose Injection, USP, 70 %, 2000 mL bags, Rx Only, Baxter
Enter fullscreen mode Exit fullscreen mode

The FDA updates the endpoint weekly, so a weekly cron job, or a scheduled GitHub Action that commits recalls.json back to the repo, is enough. Pipe the output into Slack, email or a spreadsheet and you have a recall monitor.

Where this gets harder

The script is fine for one company and one product type. It gets fiddly when you:

  • watch devices and food too (different endpoints, different fields)
  • track many firms or product categories and want one feed
  • need the snapshot to live somewhere other than the box that runs the cron
  • want the output as CSV or Excel for someone who doesn't read terminal output

If you'd rather not maintain it yourself, the same idea is available as a hosted tool. US FDA Recalls Scraper is ours, from NexGenWatch, and runs on Apify. It covers drugs, devices and food. Every run returns the current records, and in watch mode it returns only what changed since your last run, keeping the snapshot for you. It's pay-per-use: $0.02 per run, $0.10 per source check, and $0.05 per recall change it finds. A quiet week costs cents.

Either way, the lesson is the same: with recall data, what changed matters more than what's there, and openFDA only gives you what's there.


Data: openFDA drug enforcement API, last updated September 23, 2026. openFDA's own disclaimer applies: don't use it to make medical decisions.

Top comments (0)