DEV Community

Cover image for Who makes Kirkland medicine? I traced 47 labels through DailyMed and openFDA
Jeremiah Say
Jeremiah Say

Posted on

Who makes Kirkland medicine? I traced 47 labels through DailyMed and openFDA

Who makes Kirkland medicine? The box says Costco. The US government's drug label library says Costco too, because Costco is the labeler. But the same label file usually holds one more clue: an FDA application number, an establishment, or a small printed line such as "Distributed By Perrigo".

So I wrote a script that follows those clues through two free public APIs. On 7 November 2026 it listed 50 Costco labels, dropped the 3 last updated in 2010, and traced the other 47:

Company Labels
Perrigo 28
LNK International 8
Not named 2
PPI-DAC 2
Bionpharma, Patheon Softgels, Dexcel Pharma, Amneal, Guardian Drug Company, Ohm Laboratories, PL Developments 1 each

The headline is easy. The interesting part is what those rows can and can't prove, because 19 of the 47 rest on evidence that doesn't show who made the pills.

This post covers the join, the strength of each kind of evidence, and four traps in the data. The full script is at the end. It uses only the Python standard library.

The two APIs

DailyMed is the US National Library of Medicine's library of drug labels. Its REST API lists labels by labeler and returns each one as a Structured Product Labeling (SPL) XML file:

DM = "https://dailymed.nlm.nih.gov/dailymed/services/v2"

def labels():
    page, out = 1, []
    while True:
        d = json.loads(get(f"{DM}/spls.json?labeler=Costco&pagesize=100&page={page}"))
        out += d["data"]
        if page >= d["metadata"]["total_pages"]:
            return out
        page += 1
Enter fullscreen mode Exit fullscreen mode

openFDA publishes Drugs@FDA as JSON. Give it an application number and it returns the company that holds that approval:

FDA = "https://api.fda.gov/drug/drugsfda.json"

def holder(app, cache={}):
    if app not in cache:
        q = urllib.parse.quote(f'application_number:"{app}"')
        try:
            cache[app] = json.loads(get(f"{FDA}?search={q}"))["results"][0]["sponsor_name"]
        except Exception:
            cache[app] = None
        time.sleep(0.3)
    return cache[app]
Enter fullscreen mode Exit fullscreen mode

The join key is the application number inside the SPL file. It sits in an extension attribute, and for a job this small a regex is enough:

def application(xml):
    m = re.search(r'extension="((?:ANDA|NDA|BLA)\d{6})"', xml)
    return m.group(1) if m else None
Enter fullscreen mode Exit fullscreen mode

That's the whole pipeline: labeler to label, label to application, application to holder. A naive version stops here and prints "made by". It would be wrong.

Not all evidence is the same strength

An FDA approval holder has permission to market a drug. It can still use someone else's plant. A distributor sells a product, and it may not have made it. So the script keeps the strongest clue it finds and records which kind it was.

It tries these in order:

  1. The file lists a company as an establishment doing manufacture. That's a filed statement about an operation.
  2. The label prints a line such as "Manufactured by", "Packaged by" or "Distributed by", naming a company.
  3. Only the FDA approval holder is known.
if plant:
    company, how = plant, "file lists it as a manufacturer"
elif who:
    company, how = who, f"label says {verb} by"
elif held:
    company, how = HOLDERS.get(held, held), "FDA approval holder only"
else:
    company, how = "Not named", "nothing to go on"
Enter fullscreen mode Exit fullscreen mode

Here's how the 47 labels split:

Evidence Labels
File lists a manufacturer 10
Label says "Manufactured by" 1
Label says "Packaged by" 5
Label says "Distributed by" 10
FDA approval holder only 19
No company on the label file 2

Only 11 labels reach the top two rows. That's why the title says "lead to" and not "are made by".

Perrigo's 28 break down like this: 1 "Manufactured by", 5 "Packaged by", 9 "Distributed by" and 13 approval holder only. LNK's 8 are all establishment listings, and 5 of those have no application number at all, so a holder lookup alone would have missed them.

Trap 1: one company, four spellings

openFDA doesn't give you a company ID. It gives you a sponsor name, and FDA's short form varies. Perrigo arrived as:

  • PERRIGO R AND D
  • PERRIGO PHARMA INTL
  • PERRIGO
  • L PERRIGO CO

A plain Counter on that field makes Perrigo look like four mid-sized suppliers, and the biggest would hold 11 labels instead of 28. You need an explicit alias table, and each alias is a decision you should be able to defend:

HOLDERS = {"PERRIGO": "Perrigo", "PERRIGO R AND D": "Perrigo", "PERRIGO PHARMA INTL": "Perrigo",
           "L PERRIGO CO": "Perrigo", "LNK": "LNK International"}
Enter fullscreen mode Exit fullscreen mode

The fifth spelling is the one to leave alone. PPI-DAC holds 4 of these applications. No record I hold ties that string to a named company, so it stays as written. Two of those 4 labels print "Packaged By Perrigo", which is why they count for Perrigo. The other 2 stay as PPI-DAC.

Fuzzy matching could merge it without asking. Guessing a company name is the one mistake this dataset can't afford.

Trap 2: the two sources disagree

Kirkland Aller-Flo prints "Distributed By Perrigo". openFDA gives the holder of its application, ANDA207957, as HIKMA.

Neither record is wrong. They answer different questions: who sells it on, and who holds the approval. If you collapse both into a single maker column, you lose that. So keep the holder and the printed line side by side, and let the row say which one you used.

Trap 3: no application number doesn't mean no data

9 of the 47 labels have no application number, including Kirkland acetaminophen and low dose aspirin. They're sold under FDA's over-the-counter monograph rules instead of a drug-by-drug approval, so there's nothing to join on.

If your script treats a failed join as "unknown", those labels vanish. Reading the SPL's establishment list recovers most of them:

def establishment(root):
    """A company the file lists as doing the manufacture, other than Costco."""
    for e in root.findall(".//h:assignedEntity", NS):
        name = e.findtext("h:assignedOrganization/h:name", default="", namespaces=NS)
        ops = {c.get("displayName", "").lower()
               for c in e.findall(".//h:performance/h:actDefinition/h:code", NS)}
        if "manufacture" in ops and known(name):
            return known(name)
Enter fullscreen mode Exit fullscreen mode

Trap 4: a matching number doesn't mean the same pills

The tempting next step is comparing Kirkland with name brands. In our Kirkland vs name brands table, 6 of the 41 medicine comparisons list one FDA application number on both labels. Kirkland Ibuprofen IB and Motrin IB, for example, both rely on ANDA077349, held by Perrigo.

That's the closest thing to "the same product" a public record gives you. It still doesn't show the same plant or the same lot. It only shows that both labels rely on one approval.

So who makes what?

These are the answers for the most searched products, with the evidence level each one reaches:

  • Kirkland minoxidil: Perrigo. The 5% solution label says "Manufactured By Perrigo Yeruham 80500, Israel". The foam says "Distributed By Perrigo", and FDA gives Perrigo as the holder of ANDA091344.
  • Kirkland sleep aid: LNK International. The file lists LNK as doing the manufacture, and FDA gives LNK as the holder of ANDA040564.
  • Kirkland allergy medicine: it depends on the box. Aller-Tec, Aller-Fex and most AllerClear labels trace to Perrigo through their FDA approvals, while one AllerClear D 24 Hour label says "Manufactured by: Ohm Laboratories".
  • Kirkland ibuprofen: four companies, depending on the pack.

You can search all 44 products by name, Costco item number, NDC or pill mark on the Kirkland medicine page. The 47 labels fill 44 rows because one medicine can have more than one label. We don't test medicines or give medical advice, so ask a pharmacist which one suits you.

Limits

  • A record names a company. It doesn't say which plant made the bottle in your hand.
  • The company list in the script is short on purpose, so it can miss a name. Writing this post caught that on our own site: the page said the Kirkland stool softener's file named no company, but the label says "Distributed by: PL Developments". We fixed it and logged it on our corrections page. That line names a distributor, not a maker.
  • These are labels on DailyMed with Costco as the labeler, not a sample of everything Costco sells.
  • The label file may not match an older pack still on a shelf.
  • FDA asks anyone using openFDA not to rely on it for medical care decisions.

Data provided by the U.S. Food and Drug Administration (https://open.fda.gov). This post isn't endorsed by FDA.

The stamps, evidence quotes and SHA-256 fingerprints behind the site are explained on how we know, and the records are open data under CC BY 4.0, also on GitHub and archived on Zenodo with a DOI. Every claim is re-checked within six months, on the dates in our update schedule.

AI assistants helped read the records and draft this post, as they do on the site. The numbers come from the script below, run on 7 November 2026.

The full script (stdlib only)

#!/usr/bin/env python3
"""Trace Costco's Kirkland Signature drug labels from DailyMed to the company behind them.
Standard library only. Writes labels.json and prints the tally."""
import collections, json, re, time, urllib.parse, urllib.request
import xml.etree.ElementTree as ET

UA = {"User-Agent": "kirkland-label-trace/1.0"}
DM = "https://dailymed.nlm.nih.gov/dailymed/services/v2"
FDA = "https://api.fda.gov/drug/drugsfda.json"
NS = {"h": "urn:hl7-org:v3"}
LINE = re.compile(r"(Manufactured|Distributed|Packaged|Made)\s+by:?\s*(.{0,40})", re.I)
HOLDERS = {"PERRIGO": "Perrigo", "PERRIGO R AND D": "Perrigo", "PERRIGO PHARMA INTL": "Perrigo",
           "L PERRIGO CO": "Perrigo", "LNK": "LNK International"}
KNOWN = {"perrigo": "Perrigo", "lnk": "LNK International", "ohm lab": "Ohm Laboratories",
         "guardian drug": "Guardian Drug Company"}
DISTRIBUTORS = {"pl developments": "PL Developments"}  # last resort: used only when nothing better names a company


def get(url):
    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60) as r:
        return r.read().decode("utf-8", "replace")


def labels():
    page, out = 1, []
    while True:
        d = json.loads(get(f"{DM}/spls.json?labeler=Costco&pagesize=100&page={page}"))
        out += d["data"]
        if page >= d["metadata"]["total_pages"]:
            return out
        page += 1


def known(text, names=KNOWN):
    t = text.lower()
    return next((v for k, v in names.items() if k in t), None)


def application(xml):
    m = re.search(r'extension="((?:ANDA|NDA|BLA)\d{6})"', xml)
    return m.group(1) if m else None


def establishment(root):
    """A company the file lists as doing the manufacture, other than Costco."""
    for e in root.findall(".//h:assignedEntity", NS):
        name = e.findtext("h:assignedOrganization/h:name", default="", namespaces=NS)
        ops = {c.get("displayName", "").lower() for c in e.findall(".//h:performance/h:actDefinition/h:code", NS)}
        if "manufacture" in ops and known(name):
            return known(name)


def printed_line(root, names=KNOWN):
    text = " ".join(ET.tostring(root, encoding="unicode", method="text").split())
    for verb, rest in LINE.findall(text):
        if known(rest, names):
            return known(rest, names), verb.lower()
    return None, None


def holder(app, cache={}):
    if app not in cache:
        q = urllib.parse.quote(f'application_number:"{app}"')
        try:
            cache[app] = json.loads(get(f"{FDA}?search={q}"))["results"][0]["sponsor_name"]
        except Exception:
            cache[app] = None
        time.sleep(0.3)
    return cache[app]


def trace(s):
    xml = get(f"{DM}/spls/{s['setid']}.xml")
    root = ET.fromstring(xml)
    app = application(xml)
    held = holder(app) if app else None
    plant = establishment(root)
    who, verb = printed_line(root)
    if plant:
        company, how = plant, "file lists it as a manufacturer"
    elif who:
        company, how = who, f"label says {verb} by"
    elif held:
        company, how = HOLDERS.get(held, held), "FDA approval holder only"
    elif printed_line(root, DISTRIBUTORS)[0]:
        company, how = printed_line(root, DISTRIBUTORS)[0], "label says distributed by"
    else:
        company, how = "Not named", "no company on the label file"
    return {"setid": s["setid"], "title": s["title"], "published": s["published_date"],
            "application": app, "holder": held, "company": company, "how": how}


def main():
    all_labels = labels()
    rows = [trace(s) for s in all_labels if not s["published_date"].endswith("2010")]
    json.dump(rows, open("labels.json", "w"), indent=1)
    print(len(all_labels), "labels listed,", len(rows), "after dropping those last updated in 2010")
    for name, n in collections.Counter(r["company"] for r in rows).most_common():
        print(f"{n:3} {name}")
    print()
    for k, n in collections.Counter(r["how"].split(" by")[0] for r in rows).most_common():
        print(f"{n:3} {k}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Top comments (0)