OMOP Drug Exposure and Drug Era
The OMOP CDM pair in which DRUG_EXPOSURE stores one record per source dispensing or administration at the clinical-drug level and DRUG_ERA derives ingredient-level, gap-stitched exposure episodes whose persistence-gap threshold is the central judgment call governing who is "on drug" and for how long.
On this page
When a patient fills a prescription, that fill gets stored as a single row in the DRUG_EXPOSURE table — one row per trip to the pharmacy, with the drug name, fill date, and how many days' worth of pills the bottle contains. A DRUG_ERA collapses those individual fills into a single continuous-treatment span: as long as a new fill arrives within a tolerance window (the default is 30 days after the previous supply runs out), the algorithm treats the patient as still on the drug and extends the episode rather than starting a new one. The era is the unit most studies use when asking 'how long was this patient on treatment' — but the 30-day tolerance is a modeling choice, not a medical fact, and changing it to 0 or 90 days can meaningfully shift who counts as 'exposed' and for how long.
In the OMOP Common Data Model (CDM), drug exposure is represented at two distinct grains, and confusing them is a common source of silent misclassification in RWE. DRUG_EXPOSURE is the atomic table: one row per source-level dispensing, prescription, or administration, keyed on `person_id` with `drug_concept_id` mapped to the standardized vocabulary at the clinical-drug level (e.g., RxNorm clinical/branded drug), plus `drug_exposure_start_date`, `drug_exposure_end_date`, `days_supply`, `quantity`, and `route_concept_id`. DRUG_ERA is a derived table: it rolls clinical drugs up to their ingredient (via `CONCEPT_ANCESTOR`), then stitches consecutive exposures of the same ingredient into a single episode (`drug_era_start_date`, `drug_era_end_date`, `gap_days`, `drug_exposure_count`) whenever the gap between the end of one exposure and the start of the next falls within a persistence threshold. The OHDSI-default persistence window is 30 days. Drug eras are the unit most analyses treat as "a treatment episode."
Core conceptual distinction
. DRUG_EXPOSURE is what the source said happened — a faithful, claim-by-claim or order-by-order ledger at the clinical-drug grain. DRUG_ERA is an analyst's model of continuous treatment at the ingredient grain, and its boundaries are an explicit, consequential parameter, not a fact in the data. Three decisions are folded into a drug era and must be pre-specified in the estimand:
- the ingredient roll-up (a patient switching between two atorvastatin products is one statin era, but atorvastatin → rosuvastatin is two ingredient eras even though it is "continuous statin therapy");
- the end-date imputation when `days_supply` is missing or implausible (impute from quantity ÷ a typical daily dose, or a fixed default — this directly sets where person-time ends); and
- the persistence gap that allows late refills to be treated as one continuous era (0, 30, 90 days). The gap is the dominant judgment call: a 0-day gap demands seamless refills and shortens person-time, treating every late refill as discontinuation; a 90-day gap forgives long supply gaps and lengthens on-treatment time, risking immortal-time-like overcounting of exposed person-time during periods the patient held no drug.
Pros, cons, and trade-offs
.
- DRUG_ERA vs analyzing raw DRUG_EXPOSURE rows directly: Eras give you ready-made, ingredient-level episodes that map cleanly onto persistence, time-at-risk, and on-treatment follow-up; they absorb the messy "stockpiling / overlapping fills / one-day gaps" bookkeeping into a single transparent rule. Cost: the default 30-day era hides the gap choice from downstream users, and the ingredient roll-up erases within-class switching that may be the exposure contrast of interest. Prefer raw DRUG_EXPOSURE when you need clinical-drug-level detail (dose, formulation, brand, route) or when switching between agents in the same ingredient family is itself the exposure.
- OMOP DRUG_ERA vs a bespoke claims episode-construction algorithm (e.g., PDC/MPR-style stitching outside OMOP): The OMOP era algorithm is standardized, network-portable (the same code runs across CDM sites without re-mapping), and reviewable. Cost: the standard ERA derivation is deliberately simple — it does not, by default, cap stockpiling, handle inpatient bridging, or model dynamic dosing. Prefer a bespoke or extended algorithm (or `exposure-episode-construction-rwe`) when those features change the answer, and document the deviation from the standard era.
- Ingredient-level DRUG_ERA vs clinical-drug-level exposure windows: Ingredient eras are the right grain for "any statin" questions and for class-level safety; clinical-drug windows are required for dose-response, biosimilar/branded contrasts, or formulation-specific signals. They are not interchangeable, and silently using the era table for a clinical-drug question is a misclassification, not a simplification.
When NOT to use — and when it is actively misleading or dangerous
.
- Treating the default 30-day era as a neutral fact. It is a modeling choice. Reporting "exposed person-time" off a default era without a sensitivity analysis on the gap (0/30/90) hides the parameter that most moves rates — and a too-generous gap manufactures exposed person-time during true non-use, biasing safety analyses toward the null (events accrue while the patient is unprotected but still counted as "on drug").
- Using ingredient eras for a within-ingredient or dose question. The roll-up destroys exactly the contrast you need; you will silently pool brands, doses, and formulations.
- Imputing end dates from `days_supply` when `days_supply` is unreliable. In administration-based EHR data and in inpatient claims, `days_supply` is frequently missing, zero, or a placeholder. Mechanically imputing a fixed default fabricates era length and corrupts both persistence and time-at-risk; verify the `days_supply` distribution before trusting it.
- Comparing eras across CDM sites that used different ERA-derivation parameters. If site A built 30-day eras and site B built 90-day eras, "drug era" is not the same variable; network analyses must pin the persistence gap and re-derive eras uniformly, never assume the vendor-shipped DRUG_ERA table is comparable.
Data-source operational depth
.
- Claims (FFS pharmacy): The cleanest substrate — each adjudicated pharmacy claim is a DRUG_EXPOSURE row with a real `days_supply` and `quantity`; eras stitch well. Failure modes: claim reversals/voids must be removed before stitching or you double-count supply; 90-day mail-order and free samples distort `days_supply`; same-day duplicate fills inflate `drug_exposure_count`. Critically, Medicare Advantage (MA) enrollees lack fee-for-service pharmacy claims unless Part D encounter data are present — an MA-only person looks like "no fills," so an apparent washout or discontinuation is missingness, not non-use. Restrict era-based person-time to spans with observable pharmacy benefit (Part D / commercial Rx).
- EHR: DRUG_EXPOSURE here is usually an order or administration, not a dispensing; `days_supply` is often absent for inpatient administrations and outpatient orders, forcing end-date imputation. Visit-driven capture means a patient who gets care outside the system shows phantom gaps that the era algorithm will read as discontinuation. Prefer linkage to pharmacy fills to confirm the patient actually started and continued, and treat loss to follow-up as potentially informative.
- Registry: Often records treatment status or regimen rather than dispensing-level supply, so native drug eras may not be derivable at all; link to claims for the fill history before building eras, and to a death index so the era does not run past death.
- Linked claims–EHR–vital records: The ideal substrate (EHR for dose/route/severity, claims for complete fills, vital records for censoring), but order/fill/administration date discrepancies must be reconciled before era stitching, and the linkable subset introduces selection.
Worked claims example
Goal: build 30-day statin drug eras and use them to define a new-user, on-treatment window.
- Source rows (DRUG_EXPOSURE): person 1001 has pharmacy fills for atorvastatin 20 mg (NDC X) on 2023-01-05 with `days_supply` 30, a refill on 2023-02-10 (`days_supply` 30), and atorvastatin 40 mg (NDC Y) on 2023-03-20 (`days_supply` 90).
- NDC → ingredient roll-up: both NDCs map through the standardized vocabulary to the atorvastatin ingredient via `CONCEPT_ANCESTOR`, so all three rows belong to one candidate ingredient era.
- End-date imputation: each exposure `drug_exposure_end_date = drug_exposure_start_date + days_supply − 1`, giving covered windows [01-05 … 02-03], [02-10 … 03-11], [03-20 … 06-17].
- Gap-stitching walk with a 30-day persistence gap: fill 1 ends 02-03; fill 2 starts 02-10 → gap = 6 days ≤ 30 → same era. Fill 2 ends 03-11; fill 3 starts 03-20 → gap = 8 days ≤ 30 → same era. Result: one DRUG_ERA for atorvastatin, `drug_era_start_date` 2023-01-05, `drug_era_end_date` 2023-06-17, `drug_exposure_count` 3, `gap_days` 14 (total within-era gap). Had the third fill arrived on 2023-05-01 (gap = 51 days > 30), the algorithm would split it into two eras — and any safety event in late March/April would change from "on-treatment" to "post-discontinuation," a classification that flips entirely with the gap parameter.
- Propagation to design: the new-user washout requires no atorvastatin DRUG_EXPOSURE in the 365-day lookback before `drug_era_start_date` (and continuous, FFS-observable Part D enrollment so that absence of fills is real, not MA-only missingness); on-treatment follow-up runs from the era start to `drug_era_end_date` (+ an optional grace period), censoring at disenrollment, death, end of data, or switch to a different statin ingredient. The same era boundaries feed `omop-time-at-risk-cohort-exit-rwe` and persistence (`persistence-time-to-discontinuation`); report rates under 0-, 30-, and 90-day gaps as the primary sensitivity analysis.
Decision diagram
flowchart TD
Src[Source dispensings / administrations] --> DE[DRUG_EXPOSURE<br/>one row per fill at clinical-drug grain<br/>start, end, days_supply, quantity, route]
DE --> Roll[Roll up clinical drug to ingredient<br/>via CONCEPT_ANCESTOR]
Roll --> Imp[Impute end date when days_supply missing<br/>start + days_supply - 1]
Imp --> Sort[Sort by person_id, ingredient, start_date]
Sort --> Gap{Next fill starts within<br/>persistence gap of running cov_end?}
Gap -- Yes (<= gap) --> Same[Extend current era<br/>cov_end = max cov_end, end]
Gap -- No (> gap) --> New[Close era, start a new one]
Same --> ERA[DRUG_ERA<br/>start, end, gap_days, drug_exposure_count]
New --> ERA
ERA --> Use[Washout / time-at-risk / persistence<br/>report under 0 / 30 / 90 day gap]gantt title One atorvastatin DRUG_ERA under a 30-day persistence gap (claims) dateFormat YYYY-MM-DD axisFormat %b %Y section DRUG_EXPOSURE rows Fill 1 atorvastatin 20mg (30d supply) :done, f1, 2023-01-05, 30d Fill 2 atorvastatin 20mg (30d supply) :done, f2, 2023-02-10, 30d Fill 3 atorvastatin 40mg (90d supply) :done, f3, 2023-03-20, 90d section Derived DRUG_ERA Ingredient era (gaps 6d and 8d, both <= 30) :active, era, 2023-01-05, 164d
Worked example
Scenario
Person 1001 fills atorvastatin — a cholesterol-lowering drug — four times over about seven months. We want to know how many continuous treatment episodes the OMOP algorithm would build from those four fills using the default 30-day persistence gap. Each fill covers exactly as many days as its days_supply; when a new fill arrives within 30 days after the previous supply would run out, it is stitched onto the same era. If the gap exceeds 30 days, a new era begins. Both atorvastatin 20 mg and atorvastatin 40 mg roll up to the same 'atorvastatin' ingredient, so all four fills are candidates for the same era.
Dataset
Raw DRUG_EXPOSURE rows for person 1001 — exactly what an analyst would see in the OMOP pharmacy table.
| person_id | fill_date | drug | days_supply | end_date (fill_date + days_supply − 1) |
|---|---|---|---|---|
| 1001 | 2023-01-10 | atorvastatin 20 mg | 30 | 2023-02-08 |
| 1001 | 2023-02-15 | atorvastatin 20 mg | 30 | 2023-03-16 |
| 1001 | 2023-03-20 | atorvastatin 40 mg | 30 | 2023-04-18 |
| 1001 | 2023-06-15 | atorvastatin 20 mg | 30 | 2023-07-14 |
Steps
Result
- Label
2 drug eras: Era 1 spans 2023-01-10 to 2023-04-18 (99 days, 3 fills, 9 within-era gap days); Era 2 spans 2023-06-15 to 2023-07-14 (30 days, 1 fill, 0 gap days). Any outcome event in the 57-day window between Apr 19 and Jun 14 is off-treatment under the 30-day gap rule.
- Value
- Era Count
2
- Era 1 Length Days
99
- Era 2 Length Days
30
Trade-offs
Runnable example
Derive ingredient-level drug eras from an OMOP DRUG_EXPOSURE table with a configurable persistence gap. Required inputs (pre-loaded, OMOP-standard columns; no toy data created here): drug_exposure : person_id, drug_concept_id (clinical drug), drug_exposure_start_date (datetime), drug_exposure_end_date (datetime, may...
import pandas as pd
import numpy as np
PERSISTENCE_GAP = 30 # days a refill may be late before a new era starts; sensitivity-test 0 / 30 / 90
def build_drug_eras(drug_exposure: pd.DataFrame,
concept_ancestor: pd.DataFrame,
gap_days: int = PERSISTENCE_GAP) -> pd.DataFrame:
de = drug_exposure.copy()
# Roll clinical drugs up to their ingredient via CONCEPT_ANCESTOR.
anc = concept_ancestor.rename(columns={"descendant_concept_id": "drug_concept_id",
"ancestor_concept_id": "ingredient_concept_id"})
de = de.merge(anc[["drug_concept_id", "ingredient_concept_id"]], on="drug_concept_id", how="inner")
# Impute end date from days_supply when missing (start + days_supply - 1).
need = de["drug_exposure_end_date"].isna()
de.loc[need, "drug_exposure_end_date"] = (
de.loc[need, "drug_exposure_start_date"]
+ pd.to_timedelta(de.loc[need, "days_supply"].fillna(0).astype(int) - 1, unit="D"))
de = de.sort_values(["person_id", "ingredient_concept_id", "drug_exposure_start_date"])
# Carry-over: a new era starts when the current fill begins more than gap_days after the
# running maximum covered end date for this (person, ingredient). cummax handles overlap/stockpiling.
g = de.groupby(["person_id", "ingredient_concept_id"], sort=False)
prev_cov_end = g["drug_exposure_end_date"].cummax().groupby(
[de["person_id"], de["ingredient_concept_id"]]).shift()
gap = (de["drug_exposure_start_date"] - prev_cov_end).dt.days
new_era = prev_cov_end.isna() | (gap > gap_days)
de["era_id"] = new_era.groupby([de["person_id"], de["ingredient_concept_id"]]).cumsum()
eras = (de.groupby(["person_id", "ingredient_concept_id", "era_id"])
.agg(drug_era_start_date=("drug_exposure_start_date", "min"),
drug_era_end_date=("drug_exposure_end_date", "max"),
drug_exposure_count=("drug_concept_id", "size"),
covered_days=("days_supply", "sum"))
.reset_index())
# gap_days = elapsed span not covered by supply within the era.
span = (eras["drug_era_end_date"] - eras["drug_era_start_date"]).dt.days + 1
eras["gap_days"] = (span - eras["covered_days"]).clip(lower=0)
return eras.drop(columns="covered_days")Drug-era derivation with data.table (fast cummax/shift over sorted exposures). Inputs mirror the Python version: drug_exposure : person_id, drug_concept_id, drug_exposure_start_date (Date), drug_exposure_end_date (Date, may be NA), days_supply (integer, may be NA) concept_ancestor: ancestor_concept_id (ingredient),...
library(data.table)
PERSISTENCE_GAP <- 30L # sensitivity-test 0 / 30 / 90
build_drug_eras <- function(drug_exposure, concept_ancestor, gap_days = PERSISTENCE_GAP) {
de <- as.data.table(drug_exposure)
anc <- as.data.table(concept_ancestor)[, .(drug_concept_id = descendant_concept_id,
ingredient_concept_id = ancestor_concept_id)]
de <- merge(de, anc, by = "drug_concept_id", allow.cartesian = TRUE)
# Impute missing end date from days_supply.
de[is.na(drug_exposure_end_date),
drug_exposure_end_date := drug_exposure_start_date + as.integer(fifelse(is.na(days_supply), 0L, days_supply)) - 1L]
setorder(de, person_id, ingredient_concept_id, drug_exposure_start_date)
# Running max covered end date (cummax) per person-ingredient handles overlap/stockpiling.
de[, prev_cov_end := shift(cummax(as.integer(drug_exposure_end_date))),
by = .(person_id, ingredient_concept_id)]
de[, gap := as.integer(drug_exposure_start_date) - prev_cov_end]
de[, new_era := is.na(prev_cov_end) | gap > gap_days]
de[, era_id := cumsum(new_era), by = .(person_id, ingredient_concept_id)]
eras <- de[, .(drug_era_start_date = min(drug_exposure_start_date),
drug_era_end_date = max(drug_exposure_end_date),
drug_exposure_count = .N,
covered_days = sum(days_supply, na.rm = TRUE)),
by = .(person_id, ingredient_concept_id, era_id)]
eras[, gap_days := pmax(0L, as.integer(drug_era_end_date - drug_era_start_date) + 1L - covered_days)]
eras[, covered_days := NULL][]
}Drug-era derivation in SAS: PROC SQL maps clinical drugs to ingredient via CONCEPT_ANCESTOR, then a DATA step with RETAIN/cumulative-max implements the gap-stitching state machine over sorted exposures.
%let gap = 30; /* persistence gap in days; rerun at 0 and 90 for sensitivity */
/* Roll clinical drugs up to ingredient and impute missing end dates from days_supply. */
proc sql;
create table de_ing as
select e.person_id,
a.ancestor_concept_id as ingredient_concept_id,
e.drug_concept_id,
e.drug_exposure_start_date as start_dt format=date9.,
coalesce(e.drug_exposure_end_date,
e.drug_exposure_start_date + e.days_supply - 1) as end_dt format=date9.,
e.days_supply
from omop.drug_exposure e
inner join omop.concept_ancestor a
on e.drug_concept_id = a.descendant_concept_id; /* ingredient-level roll-up */
quit;
proc sort data=de_ing; by person_id ingredient_concept_id start_dt; run;
/* State machine: a new era starts when the fill begins more than &gap days after the running */
/* maximum covered end date (cov_end) for this person-ingredient. RETAIN carries state across rows. */
data eras_long;
set de_ing;
by person_id ingredient_concept_id;
retain era_id cov_end era_start covered;
if first.ingredient_concept_id then do;
era_id = 1; cov_end = .; era_start = .; covered = 0;
end;
if cov_end = . or start_dt > cov_end + &gap then do; /* gap exceeded -> new era */
if cov_end ne . then output; /* emit the completed era */
era_id + 1; era_start = start_dt; cov_end = end_dt; covered = days_supply;
end;
else do; /* same era: extend (carry-over via max) */
cov_end = max(cov_end, end_dt);
covered = covered + days_supply;
end;
if last.ingredient_concept_id then output; /* emit the final era for this group */
keep person_id ingredient_concept_id era_id era_start cov_end covered;
run;
/* Collapse to one row per era with counts and within-era gap days. */
proc sql;
create table drug_era as
select person_id, ingredient_concept_id, era_id,
era_start as drug_era_start_date format=date9.,
cov_end as drug_era_end_date format=date9.,
max(0, (cov_end - era_start + 1) - covered) as gap_days
from eras_long;
quit;Citations
- [1]Overhage JM, Ryan PB, Reich CG, Hartzema AG, Stang PE. Validation of a common data model for active safety surveillance research. Journal of the American Medical Informatics Association. 2012;19(1):54-60.
- [2]Hripcsak G, Duke JD, Shah NH, et al. Observational Health Data Sciences and Informatics (OHDSI): opportunities for observational researchers. Studies in Health Technology and Informatics. 2015;216:574-578.