← Methods repository
CONCEPTFOUNDATIONALPYTHON · R5 citations

RxNorm Drug Terminology

The National Library of Medicine's normalized naming system for clinical drugs in the US, assigning stable numeric concept identifiers (RxCUIs) to drug ingredients, dose forms, strengths, and branded products so that thousands of package-level NDC codes from any pharmacy or EHR system can all be linked to a single shared ingredient concept.

Data Standardcoding-systemdata-standardprimitivedrugsterminologyrxnormrxcuirxnav
On this page
Methods reference only. Use primary source citations and local policy before applying this in a study protocol, regulatory submission, payer dossier, or clinical decision.
In plain language

RxNorm is a public database maintained by the US National Library of Medicine that gives every drug — by its active ingredient, strength, pill form, and brand name — a stable number called an RxCUI. Think of it as a universal translator for prescription drug names: a patient could fill the same atorvastatin tablet from a dozen different generic manufacturers, each with a different barcode-style drug code (called an NDC), but all of those codes map to the same single RxCUI for "atorvastatin 20 mg oral tablet." For researchers studying which patients took which drugs, RxNorm is what allows thousands of package-level drug codes in insurance claims to be collapsed into one manageable list — one ingredient, one search.

When to use it
Prefer RxNorm for any drug-class or ingredient-level exposure definition that will be replicated, published, or submitted to a regulator; prefer raw NDC only for labeler-specific or package-specific analyses.
Use this concept to understand the vocabulary foundation; move to OMOP Drug Exposure and Drug Era for episode-construction rules and OHDSI-specific implementation patterns.
RxNorm is always the preferred NDC-normalization layer for a drug utilization study; the choice is not either/or but sequential (map first, then analyze).
Watch out for
RxNorm mapping adds an ETL step with version-management overhead; raw NDC codes are immediately available in claims without any additional lookup.
RxNorm alone does not define exposure episodes or gap-stitching logic; those are OMOP DRUG_ERA decisions layered on top.
RxNorm mapping is a data-preparation step, not an analytic design; a drug utilization study still needs to specify its population, time window, and utilization metric independently of the vocabulary choice.

RxNorm

is a normalized drug vocabulary produced and maintained by the National Library of Medicine (NLM). Its defining purpose is to give every clinically meaningful drug concept — ingredient, strength, dose form, brand, and packaged product — a single stable identifier (the RxCUI, RxNorm Concept Unique Identifier) so that systems built on different source vocabularies can exchange drug information without losing meaning. Source vocabularies that RxNorm links include First Databank (FDB), Multum, Micromedex, Gold Standard, the VA National Drug File (VANDF), MedlinePlus, and others.

NLM releases a full monthly build plus weekly incremental updates. The core RxNorm files are freely distributable without a UMLS license; however, some source-vocabulary string content embedded in the full RxNorm distribution files does require a UMLS Metathesaurus license from NLM to reproduce. Analysts using only the RxCUI identifiers, relationship tables, and the public RxNav API (which returns standard RxNorm atoms) do not require a UMLS license.

Core machinery: the RxNorm concept hierarchy

RxNorm organizes drug concepts into a lattice of term types (TTY) connected by explicit relationships:

  • IN (Ingredient): The active moiety — e.g., atorvastatin. This is the rollup target for most RWE exposure definitions. All lower-level concepts ultimately link upward to an IN via `has_ingredient`.
  • PIN (Precise Ingredient): A salt or ester form distinguished from the free base — e.g., atorvastatin calcium. Used when the salt form is analytically important.
  • MIN (Multiple Ingredients): A multi-ingredient combination, e.g., amlodipine / atorvastatin.
  • DF (Dose Form): The form alone — tablet, capsule, oral solution.
  • SCDC (Semantic Clinical Drug Component): Ingredient + strength — e.g., atorvastatin 10 mg.
  • SCD (Semantic Clinical Drug): Ingredient + strength + dose form (generic) — e.g., atorvastatin 20 mg oral tablet. This is the "clinical drug" grain at which OMOP's DRUG_EXPOSURE table is typically mapped. SCDs are the workhorse level for comparisons that need to distinguish dose.
  • SBD (Semantic Branded Drug): Like SCD but branded — e.g., Lipitor 20 mg oral tablet.
  • BN (Brand Name): The trade name alone — Lipitor.
  • GPCK / BPCK (Generic Pack / Branded Pack): Multi-component packaged products.

Relationships traverse the hierarchy. `has_ingredient` links SCD → IN. `consists_of` links SCD → SCDC. `tradename_of` links SBD → SCD. `has_dose_form` links SCD → DF. The `CONCEPT_ANCESTOR` table in OMOP CDM materializes all transitive ancestor–descendant paths so a single SQL join can find every SCD or SBD whose ancestor is the atorvastatin IN.

The workhorse RWE operation: NDC → RxCUI → ingredient rollup

A US pharmacy claim carries an NDC (National Drug Code) — an 11-digit package-level code that encodes labeler, product, and package. A single branded atorvastatin 20 mg tablet product may appear under dozens of NDCs (different package sizes, different generic manufacturers, different lot codes). The fundamental RWE step is to map each NDC to its RxCUI at the SCD level, then navigate up to the IN level.

Concretely: NDC 00071-0155-23 (Lipitor 20 mg, 90-count) → RxCUI 617310 (Lipitor 20 mg oral tablet, SBD) → RxCUI 308136 (atorvastatin 20 mg oral tablet, SCD) → RxCUI 83367 (atorvastatin, IN). This collapse is what allows an analyst to write "all atorvastatin fills" as a single ingredient RxCUI rather than maintaining a list of hundreds of NDCs that grows with every generic entrant.

Drug class expansion via RxClass

RxNorm's companion service, RxClass, links RxCUIs to external drug classification systems: ATC (Anatomical Therapeutic Chemical, WHO), MeSH pharmacologic actions, VA drug classes, and MED-RT (Medication Reference Terminology, the VA's successor to NDF-RT). An analyst building an "all statin" cohort queries RxClass for ATC C10AA (HMG CoA reductase inhibitors), retrieves all IN-level members, then maps each IN to its SCDs and NDCs. This makes the drug-class definition transparent, reproducible, and auditable — a critical property for regulatory submissions and HTA dossiers.

Operational realities for RWE analysts

Historical NDC-to-RxCUI mappings: Claims data often contain NDCs that have been withdrawn, retired, or reformulated. RxNav's `ndcstatus` and `historyndc` endpoints return the RxCUI that a retired NDC mapped to at the time it was active. Failing to use historical mappings causes unmapped NDCs (coded as "unclassified" or dropped), which selectively exclude older claims and biases incidence estimates. Retired and remapped RxCUIs: RxCUIs themselves can be retired or remapped across NLM monthly releases. A concept mapped in January may have a different status in December.

Best practice is to pin the RxNorm release date (store it alongside every mapping), use the `rxcui/status` endpoint to detect remapped concepts, and rebuild mappings when updating to a new release. Suppressible and obsolete atoms: RxNorm marks certain atoms as suppressible (TTY=OA, etc.) or obsolete; analysts should filter to active, non-suppressible atoms when pulling name strings, and validate that the RxCUI returned by an NDC lookup has a current, non-retired status. OMOP integration: In the OMOP Common Data Model, RxNorm is the standard vocabulary for the Drug domain.

Source NDC codes in DRUG_EXPOSURE are mapped to RxNorm standard concepts via `CONCEPT_RELATIONSHIP` rows with relationship `Maps to`. The DRUG_ERA derivation then rolls those clinical-drug concepts up to ingredient via `CONCEPT_ANCESTOR`. If a site's ETL mapped NDCs to non-standard concepts or used a stale RxNorm release, all downstream drug-era counts will be wrong; auditing the `vocabulary_id = 'RxNorm'` completeness in the CONCEPT table is the first QC step. ATC linkage for international comparability: RxClass provides RxCUI → ATC mappings.

Because ATC is WHO's international classification, drug lists built this way can be ported to European claims or registry studies with minimal re-mapping — a common need in multi-country HEOR submissions.

Pros, cons, and trade-offs

  • vs raw NDC lists maintained manually: RxNorm ingredient rollup is algorithmically stable: add one ingredient RxCUI to the concept set and every current and future NDC for that ingredient is automatically captured when the mapping files are refreshed. Manual NDC lists grow stale within months as new generics enter the market and old packages are retired. Prefer RxNorm rollup for any study that will be updated, replicated, or submitted to a regulator; maintain NDC-level granularity only when package-level identity matters (e.g., specific package sizes for adherence per-unit-cost calculations). The cost is that RxNorm mapping requires a one-time ETL investment and a versioning discipline that ad hoc NDC lists skip.
  • vs ATC alone for drug class identification: ATC is an internationally recognized hierarchical classification with clear levels (1-character to 7-character codes), making it natural for cross-country comparisons. RxNorm does not carry a built-in class hierarchy; class membership requires a RxClass lookup. However, ATC-to-NDC crosswalks are indirect and can miss new US generics not yet classified; RxNorm → RxClass → ATC is the more complete and timely path for US claims. Prefer RxNorm + RxClass for US-sourced studies; use ATC as the reporting layer when presenting alongside non-US data.
  • vs GPI (Generic Product Identifier) or Multum classification used in commercial claims: Some commercial claims databases (e.g., Truven MarketScan-era) ship with GPI or Multum class codes in addition to NDC. These are convenient shortcuts but are not publicly documented, not NLM-maintained, and not consistent across data vendors. Prefer RxNorm/RxCUI for reproducible, cross-database, or externally auditable work; GPI/Multum codes are acceptable for exploratory or single-database descriptive analyses where the code list will not be published or scrutinized.
  • vs brand-level HCPCS J-codes for physician-administered drugs: Many biologics and infused drugs dispensed in physician offices are billed under HCPCS Level II J-codes rather than pharmacy NDC codes. HCPCS J-codes do not appear in RxNorm; they live in a separate vocabulary (HCPCS in OMOP). An analyst building an "all adalimumab" cohort must union NDC-based pharmacy fills with HCPCS J-code-based medical claims — RxNorm handles only the former. Pair RxNorm with HCPCS J-codes for biologics and specialty infusibles; failing to do so undercounts exposure, especially in the Medicare and commercial fee-for-service populations where physician-office infusion is common.

When NOT to use — and when it is actively misleading or dangerous

  • As the sole exposure source for physician-administered specialty drugs. Biologics, chemotherapy agents, and infused medications primarily appear in medical claims under HCPCS J-codes, not pharmacy NDC codes. Using only RxNorm/NDC-based fills to define "biologic initiator" in a rheumatology or oncology cohort will systematically miss the majority of infused administrations. The bias is differential if infusion rates differ by study arm or time period.
  • Without version-pinning the RxNorm release. Applying a drug list built against the January 2023 RxNorm release to claims processed in 2025 will silently miss NDCs for generics approved after January 2023 — and those generics may be the dominant dispensed form by 2025. Always store and report the RxNorm release date alongside every mapping. Studies spanning years should re-map with each annual or semi-annual release update.
  • Without auditing unmapped NDCs. Every NDC-to-RxCUI mapping exercise produces unmapped rows. If the unmapped fraction is substantial (often 3–15% in older claims) and non-random — for example, concentrated in a specific formulary tier, therapeutic class, or calendar period — the resulting exposure definition is biased and the selection is invisible in published code lists. Always report the fraction mapped and characterize unmapped NDCs before treating the mapping as complete.
  • Without the historical NDC endpoint for old claims. An NDC that appears in claims from 2015 may have been withdrawn and will not appear in current RxNorm active files. Mapping only against the current RxNorm release drops all withdrawn-NDC rows — which represent real fills — producing artificially low utilization in earlier calendar periods and inducing a secular trend artifact.
  • Treating a drug-class RxClass query as permanent. RxClass membership (especially via ATC updates or VA class revisions) can change across releases. A statin list queried in 2020 may differ slightly from one queried in 2024 if ATC added or reclassified agents. For regulatory work, snapshot and document the drug-class membership at query time.

Data-source operational depth

  • Pharmacy claims (FFS commercial / Medicare Part D): The natural home for RxNorm mapping. Each adjudicated pharmacy claim carries an NDC; mapping to RxCUI is the first ETL step. Failure modes: claim reversals must be removed before the mapping step or a reversed fill appears as legitimate exposure; 90-day mail-order fills look identical to 30-day fills in terms of NDC but carry a larger `days_supply` that affects episode construction; free samples and manufacturer-provided doses appear in no pharmacy claim and are therefore invisible to RxNorm-based exposure windows. Medicare Advantage members' Part D utilization is often available only in encounter data rather than adjudicated FFS claims; verify that the Part D enrollment flag is present before trusting the NDC completeness for MA enrollees.
  • EHR (dispensing/prescribing records): EHR medication records may be coded in RxNorm natively (common in modern FHIR-based systems), in a source vocabulary (FDB, Multum) that RxNorm links, or as free-text strings requiring NLP normalization. Orders differ from fills; an EHR-recorded RxNorm code represents a prescription issued, not confirmed dispensing. For exposure definitions requiring confirmed fills, link EHR prescribing records to outpatient pharmacy claims. In-hospital administration records typically reference NDC or facility item codes, not RxNorm, and require a separate crosswalk step.
  • OMOP CDM sites (distributed network): The ETL from source NDC (or FDB/Multum concept) to OMOP standard RxNorm concept is the single most consequential data-quality step. Validate using the OHDSI Drug Exposure Diagnostics R package (`DrugExposureDiagnostics`): check the fraction of records with a valid `drug_concept_id > 0`, the ingredient-level coverage of your concept set, and the `days_supply` distribution for implausibles. When running across multiple CDM sites, pin the OMOP vocabulary release (CONCEPT table version) to ensure consistency; vocabulary version differences can cause a concept that maps in site A to fail in site B if site B's CONCEPT table predates the relevant RxNorm release.
  • Registry data: Disease registries rarely carry NDC-level dispensing. If the registry records treatment as a drug name string or ATC code, map through RxNorm (string-to-RxCUI via the RxNav approximate-match API, or ATC-to-RxCUI via RxClass) to enable cross-registry or claims linkage. The ATC-to-RxCUI path is usually more reliable than string matching for this purpose.

Decision diagram

flowchart TD
  NDC["NDC (package-level)\ne.g. 00071-0155-23\nLipitor 20mg 90ct"]
  SBD["SBD (Semantic Branded Drug)\nLipitor 20 mg oral tablet\nRxCUI 617310"]
  SCD["SCD (Semantic Clinical Drug)\natorvastatin 20 mg oral tablet\nRxCUI 308136"]
  SCDC["SCDC (Clinical Drug Component)\natorvastatin 20 mg\nRxCUI 1157996"]
  IN["IN (Ingredient) ← rollup target\natorvastatin\nRxCUI 83367"]
  BN["BN (Brand Name)\nLipitor\nRxCUI 41126"]
  DF["DF (Dose Form)\noral tablet\nRxCUI 317541"]
  NDC -->|"Maps to (via RXNSAT)"| SBD
  NDC -->|"Generic NDCs map directly"| SCD
  SBD -->|"tradename_of"| SCD
  SCD -->|"consists_of"| SCDC
  SCD -->|"has_dose_form"| DF
  SCDC -->|"has_ingredient"| IN
  SBD -->|"has_brand_name"| BN
  IN -->|"← RWE exposure def uses this level"| IN
The RxNorm concept hierarchy for atorvastatin products. NDC codes at the package level map through branded (SBD) or directly to generic clinical drug (SCD) concepts, which roll up to the ingredient (IN) via has_ingredient. The IN level is the standard rollup target for RWE drug exposure definitions. RxClass links the IN level outward to ATC drug classes (C10AA for statins) and VA drug classes.

Worked example

Scenario

A pharmacoepidemiologist is building a "new statin user" cohort from commercial insurance pharmacy claims. The raw claims table contains NDC codes — one per prescription fill. Six fills appear for patient 3001 between January and June 2023, representing three different statin products from two different manufacturers in two different strengths. The analyst needs to:

  1. map each NDC to its RxCUI,
  2. identify which RxCUI grain corresponds to the generic clinical drug (SCD) and then the ingredient (IN), and
  3. confirm all six fills belong to the same ingredient so they can be combined into one continuous statin exposure episode. The example walks through the mapping chain for each fill.

Dataset

Six pharmacy claim rows for one patient. Each NDC is a distinct product code (different manufacturer, strength, or package), yet all six represent statin fills that should collapse to a single ingredient for cohort purposes.

person_idfill_datendcproduct_descriptiondays_supply
30012023-01-0800071-0155-23Lipitor 20mg tab 90ct (Pfizer)90
30012023-01-0800071-0156-23Lipitor 40mg tab 90ct (Pfizer)90
30012023-02-1516729-0120-01atorvastatin 20mg tab 90ct (Accord)90
30012023-02-1516729-0121-01atorvastatin 40mg tab 90ct (Accord)90
30012023-04-1068180-0221-06atorvastatin 20mg tab 90ct (Lupin)90
30012023-06-0168180-0222-06atorvastatin 40mg tab 90ct (Lupin)90

Steps

1Map each NDC to its SCD-level RxCUI using the RxNav API (or the RXNCONSO/RXNSAT files offline). 00071-0155-23 -> RxCUI 617310 (Lipitor 20 mg oral tablet, SBD) then its generic SCD RxCUI 308136 (atorvastatin 20 mg oral tablet). 00071-0156-23 -> RxCUI 617311 (Lipitor 40 mg oral tablet, SBD) -> SCD RxCUI 308137 (atorvastatin 40 mg oral tablet). The four generic NDCs map directly to SCD RxCUIs 308136 and 308137 without a branded intermediate.
2Roll each SCD RxCUI up to the ingredient (IN) level via the "has_ingredient" relationship. Both SCD 308136 (atorvastatin 20 mg oral tablet) and SCD 308137 (atorvastatin 40 mg oral tablet) share the same ingredient RxCUI 83367 (atorvastatin, IN). All six NDCs therefore resolve to one and the same ingredient.
3Count the distinct levels: 6 NDCs -> 2 SCDs (one per strength) -> 1 ingredient (atorvastatin). All six fills belong to a single ingredient concept, so they can be combined into one continuous statin exposure window for the new-user cohort definition.
4Verify: the analyst queries RxNav for each NDC's status (to confirm no retired-NDC issue for these 2023 fills), checks the ingredient name is "atorvastatin" (RxCUI 83367), and records the RxNorm release date (e.g., 2023-07-03) used for the mapping so the study is reproducible.

Result

6 NDCs = 2 SCDs = 1 ingredient (atorvastatin, RxCUI 83367). All six pharmacy claim rows for patient 3001 map to the same ingredient. A washout query using RxCUI 83367 and its descendants in CONCEPT_ANCESTOR will capture all six product variants with a single concept set entry, eliminating the need to manually curate a 6-item NDC list that would grow stale as new generics enter the market.

Trade-offs

Pros of this
RxNorm provides a stable ingredient-level concept that absorbs new generic NDCs automatically on each release; NDC lists become stale within months as generics enter the market. RxNorm also enables class-level querying via RxClass (ATC, VA classes) that NDC alone cannot support.
Pros of this
RxNorm is the upstream vocabulary layer that makes OMOP DRUG_EXPOSURE standard concepts meaningful; understanding RxNorm TTY levels explains why DRUG_EXPOSURE sits at the clinical-drug grain and DRUG_ERA rolls to ingredient — the CDM design mirrors the RxNorm lattice.
Pros of this
RxNorm ingredient rollup produces the clean, de-duplicated drug-class lists that drug utilization studies require to count treated patients and calculate DDD denominators accurately; utilization metrics computed from raw NDCs are vulnerable to both double-counting and gaps.

Runnable example

Two patterns: (A) real-time RxNav REST API — for each NDC, retrieve its RxCUI and walk up to ingredient level; suitable for small to medium volumes with caching. (B) offline RRF join — load RXNCONSO and RXNSAT from the NLM monthly release files and join locally;

requires: requests · pandas
import requests
import pandas as pd
from functools import lru_cache

RXNAV_BASE = "https://rxnav.nlm.nih.gov/REST"
RXNORM_RELEASE = "2024-01-03"   # pin the release date used for this mapping run


# ── PATTERN A: RxNav REST API (real-time, cached) ────────────────────────────

@lru_cache(maxsize=8192)
def ndc_to_rxcui(ndc: str) -> dict | None:
    """Return the current RxCUI and term type for an 11-digit NDC, or None if not found.
    Strips hyphens; tries historical lookup if current lookup returns nothing."""
    ndc = ndc.replace("-", "")
    # 1. Current NDC lookup
    r = requests.get(f"{RXNAV_BASE}/ndcstatus.json", params={"ndc": ndc}, timeout=10)
    if r.ok:
        data = r.json().get("ndcStatus", {})
        status = data.get("status", "")
        if status == "ACTIVE":
            rxcui = data.get("rxcui", "")
            if rxcui:
                return {"rxcui": rxcui, "status": "ACTIVE", "source": "current"}
    # 2. Historical NDC lookup for retired NDCs (common in older claims)
    r2 = requests.get(f"{RXNAV_BASE}/historyndc.json", params={"ndc": ndc}, timeout=10)
    if r2.ok:
        hist = r2.json().get("historicalNdcConcept", {}).get("historicalNdcTime", [])
        if hist:
            # return the most recent historical RxCUI
            latest = hist[-1]
            return {"rxcui": latest.get("rxcui", ""), "status": "HISTORICAL",
                    "source": "historyndc", "start": latest.get("startDate"),
                    "end": latest.get("endDate")}
    return None


@lru_cache(maxsize=4096)
def rxcui_to_ingredient(rxcui: str) -> str | None:
    """Walk from any drug RxCUI up to its ingredient (IN-level) RxCUI via has_ingredient."""
    r = requests.get(f"{RXNAV_BASE}/rxcui/{rxcui}/related.json",
                     params={"tty": "IN"}, timeout=10)
    if r.ok:
        groups = r.json().get("relatedGroup", {}).get("conceptGroup", [])
        for g in groups:
            if g.get("tty") == "IN":
                concepts = g.get("conceptProperties", [])
                if concepts:
                    return concepts[0]["rxcui"]   # first ingredient RxCUI
    return None


def map_claims_ndcs(claims_df: pd.DataFrame) -> pd.DataFrame:
    """Map a claims DataFrame with 'ndc' column to SCD-level RxCUI and ingredient RxCUI.
    Adds: rxcui_scd, rxcui_ingredient, ndc_status, rxnorm_release."""
    results = []
    for ndc in claims_df["ndc"].unique():
        lookup = ndc_to_rxcui(str(ndc))
        if lookup is None:
            results.append({"ndc": ndc, "rxcui_scd": None, "rxcui_ingredient": None,
                             "ndc_status": "UNMAPPED"})
            continue
        rxcui_scd = lookup["rxcui"]
        ing = rxcui_to_ingredient(rxcui_scd) if rxcui_scd else None
        results.append({"ndc": ndc, "rxcui_scd": rxcui_scd, "rxcui_ingredient": ing,
                         "ndc_status": lookup["status"]})
    mapping = pd.DataFrame(results)
    mapping["rxnorm_release"] = RXNORM_RELEASE
    merged = claims_df.merge(mapping, on="ndc", how="left")
    # Audit: report unmapped fraction
    n_total = len(merged)
    n_unmapped = merged["rxcui_scd"].isna().sum()
    print(f"NDC mapping: {n_total - n_unmapped}/{n_total} mapped "
          f"({100*(1 - n_unmapped/n_total):.1f}%); "
          f"{n_unmapped} unmapped ({100*n_unmapped/n_total:.1f}%)")
    return merged


# ── PATTERN B: Offline RRF join (bulk, reproducible) ─────────────────────────

def build_ndc_to_ingredient_from_rrf(
    rxnconso_path: str,   # path to RXNCONSO.RRF from the NLM release zip
    rxnsat_path: str,     # path to RXNSAT.RRF
) -> pd.DataFrame:
    """Build a local NDC -> ingredient mapping table from the downloaded NLM RRF files.
    Returns a DataFrame with columns: ndc, rxcui_scd, scd_name, rxcui_ingredient, ing_name."""

    # RXNCONSO columns (subset needed)
    conso_cols = ["RXCUI", "LAT", "TS", "LUI", "STT", "SUI", "ISPREF",
                  "RXAUI", "SAUI", "SCUI", "SDUI", "SAB", "TTY", "CODE",
                  "STR", "SRL", "SUPPRESS", "CVF"]
    # Load only the RxNorm source atoms, non-suppressible
    conso = pd.read_csv(rxnconso_path, sep="|", header=None, names=conso_cols,
                        usecols=["RXCUI", "SAB", "TTY", "STR", "SUPPRESS"])
    rxnorm_atoms = conso[(conso["SAB"] == "RXNORM") & (conso["SUPPRESS"] != "Y")].copy()

    # SCD and IN atoms
    scd_atoms = rxnorm_atoms[rxnorm_atoms["TTY"] == "SCD"][["RXCUI", "STR"]].rename(
        columns={"RXCUI": "rxcui_scd", "STR": "scd_name"})
    in_atoms = rxnorm_atoms[rxnorm_atoms["TTY"] == "IN"][["RXCUI", "STR"]].rename(
        columns={"RXCUI": "rxcui_ingredient", "STR": "ing_name"})

    # RXNSAT: NDC attribute (ATN='NDC') links a RxCUI to its NDC strings
    sat_cols = ["RXCUI", "LUI", "SUI", "RXAUI", "STYPE", "CODE", "ATUI",
                "SATUI", "ATN", "SAB", "ATV", "SUPPRESS", "CVF"]
    sat = pd.read_csv(rxnsat_path, sep="|", header=None, names=sat_cols,
                      usecols=["RXCUI", "ATN", "ATV", "SUPPRESS"])
    ndc_sat = sat[(sat["ATN"] == "NDC") & (sat["SUPPRESS"] != "Y")][["RXCUI", "ATV"]].rename(
        columns={"RXCUI": "rxcui_scd", "ATV": "ndc"})

    # Build SCD -> ingredient map via RXNREL (has_ingredient)
    # (For simplicity, use the CONCEPT_ANCESTOR-equivalent: any SCD's ingredient
    # is the IN atom whose RXCUI appears as the object of has_ingredient for that SCD.)
    # This minimal version reads it directly from RXNCONSO by matching SCD atoms to
    # their IN ancestors via RXNREL; a full implementation loads RXNREL.RRF similarly.
    # Here we return the NDC->SCD join as the core; the caller uses rxcui_to_ingredient()
    # for the SCD->ingredient step, or loads RXNREL.RRF in the same pattern.
    result = ndc_sat.merge(scd_atoms, on="rxcui_scd", how="left")
    print(f"Offline RRF: {len(result)} NDC->SCD rows loaded from {rxnsat_path}")
    return result

Citations

FOUNDATIONAL / METHODS
  1. [1]Nelson SJ, Zeng K, Kilbourne J, Powell T, Moore R. Normalized names for clinical drugs: RxNorm at 6 years. Journal of the American Medical Informatics Association. 2011;18(4):441-448.
  2. [2]Bodenreider O. The Unified Medical Language System (UMLS): integrating biomedical terminology. Nucleic Acids Research. 2004;32(Database issue):D267-270.
APPLIED EXAMPLES
  1. [3]Brunelli SM. Use of prescription drug claims data to identify lipid-lowering medication exposure in pharmacoepidemiology studies: potential pitfalls. Pharmacoepidemiology and Drug Safety. 2016;25(7):844-846.
REPORTING & GUIDANCE
  1. [4]National Library of Medicine. RxNorm. Bethesda, MD: NLM. Accessed 2026.
  2. [5]Lister Hill National Center for Biomedical Communications, NLM. RxNav — RxNorm Application Programming Interface. Bethesda, MD: NLM. Accessed 2026.