← Methods repository
concept

Route-of-Administration Differences in RWE

The route by which a drug is delivered (oral, subcutaneous, intravenous, intramuscular, inhaled, topical) determines which administrative artifact records "use" — a pharmacy dispensing claim versus a medical administration claim — and therefore forces route-specific exposure, adherence, HCRU, and cost definitions in real-world data.

Exposure_Definitionroute-of-administrationoral-vs-injectablebiologicsadherenceexposure-definitionhcruclaims-codingj-code
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

When researchers study how well patients take their medications using insurance claims data, the route of administration — whether a drug is swallowed as a pill or injected by a clinician — determines which part of the claims database even records the drug use. Oral pills generate pharmacy claims that include a field called days_supply, telling you how many days that one bottle should last; clinician-administered injectables and infusions instead generate medical claims that carry a J-code for the drug and an administration code, but have no days_supply field at all. Because these two data artifacts are structured so differently, you cannot use the same math to measure how regularly a patient received either drug — using a single rule for both routes silently produces wrong answers.

Route of administration (ROA)

is not a clinical footnote in real-world evidence; it is the single variable that decides which stream of administrative data even contains the exposure, and consequently how exposure, adherence, persistence, healthcare resource utilization (HCRU), and cost must be operationalized. Oral and self-administered subcutaneous (SC) pen drugs surface as pharmacy claims (NDC + `fill_date` + `days_supply`). Clinician-administered injectables and infusions (IV, many SC and IM biologics) surface as medical claims — an administration procedure code (e.g., CPT 96365/96413 for IV infusion, 96372 for therapeutic SC/IM injection) paired with a HCPCS J-code for the drug, with no `days_supply` field at all. Treating these two artifacts with one exposure rule is the most common and most damaging ROA error in claims-based RWE.

Core conceptual distinction

The conceptual move is to recognize that "exposure" is observed through different measurement instruments by route, and each instrument has a different missing-data and timing structure. (1) Pharmacy-captured (oral, self-injected pens): the dispensing event predicts a window of supply via `days_supply`; adherence is the Proportion of Days Covered (PDC) or MPR over fills. The fill is a proxy for ingestion — dispensing is not administration. (2) Medical-captured (clinician-administered SC/IM/IV): there is no supply window; the administration date is the exposure, and adherence must be redefined as the proportion of label-scheduled doses actually administered within a grace window around each due date (e.g., a q4-week biologic: 14 expected doses/year, gap >7 days from the due date = a missed dose). (3) Inhaled, topical, and as-needed routes blur the line: a rescue inhaler dispensed with a `days_supply` is not used on a fixed daily schedule, so PDC computed mechanically overstates "coverage." The estimand you can defend is route-conditional: a PDC-based adherence estimand for oral fills and a dose-completion estimand for administered drugs are different quantities and must never be pooled into one "adherence" variable across arms with different routes.

Pros, cons, and trade-offs

- Route-specific dose-completion vs. a single PDC applied to all routes: Computing PDC from fills works for orals but is undefined for J-code administrations (no `days_supply`); analysts who force it either drop the injectable arm or impute a supply window, both of which bias the comparison. Dose-completion (administered/expected) is the correct instrument for clinician-administered drugs. Cost: dose-completion requires the label dosing interval and a grace rule, and it is not numerically comparable to PDC — a q12-week biologic can look "more adherent" simply because fewer decision points exist. Prefer route-specific definitions whenever a study contrasts oral and administered therapies (RA, IBD, psoriasis, oncology, MS). - vs. treating an administration claim as a dispensing claim (the naive single-rule approach): Mapping CPT/J-code events into a fill table and assigning a fixed `days_supply` is fast but invents a coverage window the data never recorded, and it double-counts when the drug J-code and the administration CPT both appear on the same encounter. Prefer explicit `claim_type` routing (pharmacy vs. medical) before any adherence math. - vs. ignoring ROA and using time-on-drug / persistence only: Persistence (time to a treatment gap) sidesteps the PDC-vs-dose-completion problem and is route-robust, but it discards intensity-of-use information and is sensitive to the gap definition, which itself is route-dependent (a 60-day gap means something different for a daily pill than for a q8-week infusion). Prefer persistence as a robust secondary endpoint, not a replacement for a route-correct adherence measure.

When to use

Any RWE study that (a) contrasts drugs given by different routes, (b) reports adherence, persistence, HCRU, or cost for a clinician-administered (J-code) therapy, or (c) builds episodes for an infused/injected biologic. Use ROA-aware logic to set `claim_type` routing first, then apply PDC/MPR to pharmacy fills and dose-completion to medical administrations, and stratify or sensitivity-test every adherence/HCRU result by route in mixed-route therapeutic areas.

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

- Single-route, single-arm pharmacy studies (e.g., statin adherence): plain PDC is correct and ROA branching is unnecessary complexity. Do not invent a dose-completion track for a drug that only ever appears as a pharmacy fill. - Pooling routes into one "adherence" covariate or outcome is actively misleading: it manufactures a spurious route effect (longer-interval injectables score higher mechanically) that can masquerade as a real comparative-effectiveness signal. If your two arms differ in route, an unadjusted adherence contrast is confounded by the measurement instrument itself. - Buy-and-bill / physician-administered drugs in a pharmacy-benefit-only dataset: if you only have Part D / pharmacy claims, the IV/infused arm is structurally absent, not non-adherent. Concluding "low use" here is a data-coverage artifact, not a finding. - Inhaled and topical drugs treated as fixed-schedule maintenance when much use is as-needed: PDC computed on rescue-inhaler `days_supply` is meaningless and dangerous if fed into an outcomes model as a confounder.

Data-source operational depth

- Claims (FFS): Orals and self-injected pens = pharmacy claims (NDC + `fill_date` + `days_supply`); clinician-administered = medical claims (administration CPT/HCPCS + drug J-code). Failure modes: (i) J-codes carry no `days_supply` — adherence must be dose-completion against the label interval, not PDC. (ii) Drug + administration on the same encounter invite double-counting of cost and dose; de-duplicate to one administration per drug per service date. (iii) Units on J-codes are billed in label-defined increments (e.g., "per 10 mg"); a single dose can be multiple J-code line units, so a naive line count overstates dose frequency — collapse to one administration per service date. (iv) Immortal-time bias in procedure/infusion studies: if follow-up starts at diagnosis but the first infusion occurs weeks later, the pre-infusion interval is immortal; set time zero at the first administration. - Claims (Medicare Advantage vs. FFS): MA encounter data are notoriously incomplete for physician-administered (Part B buy-and-bill) drugs and infusion HCRU; MA-only person-time can make an infused arm look unused or low-utilizing. Restrict to FFS Parts A/B/D (or commercial medical+pharmacy) enrollees and exclude MA-only person-time before computing route-specific exposure or HCRU. - Competing risks differ by route in elderly claims populations: infused biologics concentrate in older, sicker patients (e.g., rituximab in refractory disease); differential mortality as a competing risk by route biases naive incidence and persistence comparisons — use cause-specific or subdistribution models, not Kaplan–Meier complements, when death rates differ across routes. - EHR: Administered drugs appear in the Medication Administration Record (MAR) with exact administration timestamp and dose — superior to claims for infusions — while oral outpatient fills are only the e-prescribing order, not proof of dispensing or ingestion; link to pharmacy fills to confirm the patient actually started. Visit-driven capture means a patient who leaves the system differentially loses MAR data. - Registry: Strong for indication, dose, and adjudicated outcomes (often the best source for infused oncology/rare-disease regimens) but typically weak for complete oral fill history; link to claims for the pharmacy stream and to a death index for competing-risk censoring.

Worked claims example — adherence to an oral JAK inhibitor vs. a q4-week SC biologic in RA (FFS claims). Cohort: adults with ≥2 RA diagnoses and 365 days of continuous medical+pharmacy FFS enrollment (exclude MA-only person-time) before the first study drug. Oral arm (pharmacy stream): JAK inhibitor dispensed as NDC fills with `days_supply = 30`. Over 365 days the patient has fills on days 1, 32, 70, 100, 130, 165, 200, 235, 270, 305 (10 × 30-day fills). Stitch supply with carry-over (early refills extend the covered window, capped at the observation end), count distinct covered days, divide by 365: covered days ≈ 300 → PDC ≈ 82%. Injectable arm (medical stream): q4-week SC biologic billed as CPT 96372 + J-code on administration dates 1, 30, 62, 120, 150, 178, 206, 234, 262, 290. The label interval is 28 days, so expected doses in 365 days = ⌊365/28⌋ + 1 = 14; with a ±7-day grace window each administration is "on time" only if within 7 days of its scheduled due date, where the schedule is rolling — each on-time dose re-anchors the next due date (last-dose-anchored), so a single late dose does not desynchronize the rest of the year. The administrations on days 1, 30, 62 all land within grace of their rolling due dates; the day-90 due date (62 + 28) has no administration within ±7 days, so it is a missed dose, and the next on-time dose is the day-120 administration, which re-anchors the schedule from there. Counting the on-time administrations against the 14 expected doses gives on-time administrations = 10 of 14 expected → dose-completion ≈ 71%. The two numbers are not comparable: the oral 82% counts days of supply, the injectable 71% counts scheduled administrations met. Reporting them side by side as "adherence" without labeling the instrument would falsely suggest the oral drug is more adherent, when part of the gap is purely the difference in measurement. HCRU/cost also diverge by construction: the injectable arm generates 10 outpatient administration encounters (facility/professional fees + drug), the oral arm generates only pharmacy claims; an all-cause cost comparison must attribute administration-visit cost to the injectable arm to avoid spuriously favoring it. Because biologics-heavy therapeutic areas almost always mix routes, every adherence, persistence, HCRU, and cost endpoint here should be reported route-stratified, never pooled.

Worked example

Scenario

A researcher is comparing adherence for two rheumatoid arthritis treatments over one year: an oral JAK inhibitor taken daily (pharmacy benefit, pill) and a subcutaneous biologic injected every four weeks by a nurse (medical benefit, J-code). Both patients have exactly one year of claims. The researcher wants to know what the raw claims rows look like for each route and why the same adherence formula cannot be applied to both.

Dataset

Claims rows for the same patient-year, split by route. The oral drug appears in the pharmacy table with days_supply; the injectable appears in the medical table with a J-code and no days_supply field.

person_idclaim_tableservice_datedrug_identifierdays_supplyadmin_code
2001pharmacy2023-01-01NDC: JAK inhibitor30n/a
2001pharmacy2023-01-29NDC: JAK inhibitor30n/a
2001pharmacy2023-03-05NDC: JAK inhibitor30n/a
2002medical2023-01-01J-code: biologicNONECPT 96372
2002medical2023-01-29J-code: biologicNONECPT 96372
2002medical2023-02-26J-code: biologicNONECPT 96372
2002medical2023-04-02J-code: biologicNONECPT 96372

Steps

  • For patient 2001 (oral): each pharmacy row has a days_supply of 30, so Fill 1 covers Jan 1-Jan 30, Fill 2 covers Jan 29-Feb 27 (two-day early refill, so the carry-over rule extends coverage rather than creating a gap), and Fill 3 covers Mar 5-Apr 3. You count every calendar day that was covered and divide by 365 to get PDC.

  • For patient 2002 (injectable): every medical row has NONE in the days_supply column because the drug is injected and fully consumed at the visit — there is no supply to carry home. PDC is mathematically impossible to compute from these rows.

  • For patient 2002, the correct approach is dose-completion: the biologic label says one injection every 28 days, so 14 doses are expected in a year. Count how many medical claims fall within a reasonable grace window (for example, plus or minus 7 days) of each scheduled due date. Administrations on Jan 1, Jan 29, Feb 26, and Apr 2 all land within 7 days of their rolling due dates, so each counts as on-time.

  • The oral patient ends up with a PDC measured in fraction of days covered; the injectable patient ends up with a dose-completion rate measured in fraction of scheduled administrations met. These are two different quantities — the same number (say, 0.80) means completely different things in each column.

Result

Patient 2001 (oral): PDC can be calculated because days_supply exists on every pharmacy row; the coverage fraction is days-based. Patient 2002 (injectable): PDC cannot be calculated because days_supply is NONE on every medical row; adherence must be measured as dose-completion (scheduled administrations met divided by expected administrations). Reporting both as a single adherence number in a comparative study would conflate two different measurement instruments and produce a misleading comparison.

Runnable example

python implementation

Route-aware exposure and adherence from claims. Required inputs (cleaned, de-duplicated): pharmacy : person_id, fill_date (datetime), ndc, days_supply (int) # oral / self-dispensed medical : person_id, service_date (datetime), admin_cpt, jcode #...

import pandas as pd
import numpy as np

OBS_DAYS = 365            # observation window length from each person's index date
DOSE_INTERVAL_DAYS = 28   # label dosing interval for the injectable (e.g., q4 weeks)
GRACE_DAYS = 7            # +/- window around each scheduled due date

def oral_pdc(pharmacy: pd.DataFrame, index_date: pd.Series) -> pd.DataFrame:
    """PDC with carry-over: early refills extend the covered window, capped at observation end."""
    rx = pharmacy.merge(index_date.rename("index_date"), left_on="person_id", right_index=True)
    rx = rx[(rx["fill_date"] >= rx["index_date"]) &
            (rx["fill_date"] < rx["index_date"] + pd.Timedelta(days=OBS_DAYS))]
    rx = rx.sort_values(["person_id", "fill_date"])
    out = {}
    for pid, g in rx.groupby("person_id"):
        obs_end = g["index_date"].iloc[0] + pd.Timedelta(days=OBS_DAYS)
        covered = np.zeros(OBS_DAYS, dtype=bool)
        cursor = g["index_date"].iloc[0]  # next free day for carry-over stacking
        for _, row in g.iterrows():
            start = max(row["fill_date"], cursor)             # stack supply after prior runs out
            end = min(start + pd.Timedelta(days=int(row["days_supply"])), obs_end)
            if end > start:
                s = (start - g["index_date"].iloc[0]).days
                e = (end   - g["index_date"].iloc[0]).days
                covered[s:e] = True
                cursor = end
        out[pid] = covered.sum() / OBS_DAYS
    return pd.Series(out, name="oral_pdc")

def injectable_dose_completion(medical: pd.DataFrame, index_date: pd.Series) -> pd.DataFrame:
    """Administered doses met within +/- GRACE_DAYS of each scheduled due date / expected doses.
    Rolling (last-dose-anchored) schedule: the next due date is set from the last on-time
    administration, so an isolated late/missed dose does not desynchronize the whole grid.
    One administration per service date (de-dup J-code line units / co-billed admin CPT)."""
    md = medical.merge(index_date.rename("index_date"), left_on="person_id", right_index=True)
    md = md[(md["service_date"] >= md["index_date"]) &
            (md["service_date"] < md["index_date"] + pd.Timedelta(days=OBS_DAYS))]
    md = md.drop_duplicates(["person_id", "service_date"])   # collapse line units to one admin/day
    out = {}
    for pid, g in md.groupby("person_id"):
        idx0 = g["index_date"].iloc[0]
        expected = OBS_DAYS // DOSE_INTERVAL_DAYS + 1
        obs_end = idx0 + pd.Timedelta(days=OBS_DAYS)
        admin = sorted(g["service_date"].tolist())
        used = [False] * len(admin)
        on_time = 0
        due = idx0                                           # first due = index; re-anchored to last on-time admin
        slots = 0
        while due < obs_end and slots < expected:            # walk one scheduled dose at a time
            slots += 1
            best = None
            for i, a in enumerate(admin):
                if not used[i] and abs((a - due).days) <= GRACE_DAYS:
                    if best is None or abs((a - due).days) < abs((admin[best] - due).days):
                        best = i
            if best is not None:                             # on-time: re-anchor next due to this admin
                used[best] = True
                on_time += 1
                due = admin[best] + pd.Timedelta(days=DOSE_INTERVAL_DAYS)
            else:                                            # missed: advance one nominal interval
                due = due + pd.Timedelta(days=DOSE_INTERVAL_DAYS)
        out[pid] = on_time / expected
    return pd.Series(out, name="injectable_dose_completion")

def route_adherence(pharmacy, medical, enroll):
    # FFS-observable only: drop MA-only person-time (physician-administered drugs absent in MA encounters).
    ffs = enroll.loc[~enroll["ma_only"], "person_id"].unique()
    pharmacy = pharmacy[pharmacy["person_id"].isin(ffs)]
    medical  = medical[medical["person_id"].isin(ffs)]
    # Index date = first observed exposure of EITHER route (time zero); avoids immortal time for infusions.
    first_rx = pharmacy.groupby("person_id")["fill_date"].min()
    first_md = medical.groupby("person_id")["service_date"].min()
    index_date = pd.concat([first_rx, first_md], axis=1).min(axis=1)
    return pd.concat([oral_pdc(pharmacy, index_date),
                      injectable_dose_completion(medical, index_date)], axis=1)
r implementation

Route-aware exposure and adherence with data.table. Inputs mirror the Python version: pharmacy : person_id, fill_date (Date), ndc, days_supply (integer) # oral / self-dispensed medical : person_id, service_date (Date), admin_cpt, jcode #...

library(data.table)

OBS_DAYS <- 365L
DOSE_INTERVAL_DAYS <- 28L
GRACE_DAYS <- 7L

route_adherence <- function(pharmacy, medical, enroll) {
  setDT(pharmacy); setDT(medical); setDT(enroll)
  ffs <- enroll[ma_only == FALSE, unique(person_id)]   # drop MA-only person-time
  pharmacy <- pharmacy[person_id %chin% ffs]
  medical  <- medical[person_id %chin% ffs]

  # Time zero = first exposure of either route (no immortal time for infused arms).
  idx <- merge(pharmacy[, .(rx0 = min(fill_date)), by = person_id],
               medical[,  .(md0 = min(service_date)), by = person_id],
               by = "person_id", all = TRUE)
  idx[, index_date := pmin(rx0, md0, na.rm = TRUE)]

  # Oral PDC with carry-over: early refills extend the covered window, capped at obs end.
  rx <- merge(pharmacy, idx[, .(person_id, index_date)], by = "person_id")
  rx <- rx[fill_date >= index_date & fill_date < index_date + OBS_DAYS][order(person_id, fill_date)]
  oral <- rx[, {
    cursor <- index_date[1]; obs_end <- index_date[1] + OBS_DAYS
    covered <- logical(OBS_DAYS)
    for (i in seq_len(.N)) {
      start <- max(fill_date[i], cursor)               # stack after prior supply runs out
      end   <- min(start + days_supply[i], obs_end)
      if (end > start) {
        s <- as.integer(start - index_date[1]); e <- as.integer(end - index_date[1])
        covered[(s + 1L):e] <- TRUE; cursor <- end
      }
    }
    .(oral_pdc = sum(covered) / OBS_DAYS)
  }, by = person_id]

  # Injectable dose-completion: one admin per service date, matched to a rolling (last-dose-anchored)
  # schedule +/- grace. Each on-time dose re-anchors the next due date, so a single late/missed dose
  # does not desynchronize the whole grid.
  md <- merge(medical, idx[, .(person_id, index_date)], by = "person_id")
  md <- unique(md[service_date >= index_date & service_date < index_date + OBS_DAYS],
               by = c("person_id", "service_date"))
  inj <- md[, {
    expected <- OBS_DAYS %/% DOSE_INTERVAL_DAYS + 1L
    obs_end  <- index_date[1] + OBS_DAYS
    admin <- sort(service_date); used <- logical(length(admin)); on_time <- 0L
    due <- index_date[1]; slots <- 0L
    while (due < obs_end && slots < expected) {        # walk one scheduled dose at a time
      slots <- slots + 1L
      best <- NA_integer_
      for (i in seq_along(admin)) {
        if (!used[i] && abs(as.integer(admin[i] - due)) <= GRACE_DAYS) {
          if (is.na(best) || abs(as.integer(admin[i] - due)) < abs(as.integer(admin[best] - due))) best <- i
        }
      }
      if (!is.na(best)) {                              # on-time: re-anchor next due to this admin
        used[best] <- TRUE; on_time <- on_time + 1L
        due <- admin[best] + DOSE_INTERVAL_DAYS
      } else {                                         # missed: advance one nominal interval
        due <- due + DOSE_INTERVAL_DAYS
      }
    }
    .(injectable_dose_completion = on_time / expected)
  }, by = person_id]

  merge(oral, inj, by = "person_id", all = TRUE)
}