OMOP CDM Method Patterns for RWE
A family of cohort-construction patterns that build exposure, outcome, time-at-risk, and covariate definitions directly against the standardized OMOP Common Data Model tables (PERSON, OBSERVATION_PERIOD, DRUG_EXPOSURE/DRUG_ERA, CONDITION_OCCURRENCE/CONDITION_ERA, plus the standardized vocabulary) so the same analytic protocol runs reproducibly across many independent databases.
On this page
Explore this method family
The OMOP Common Data Model (CDM) is a standardized blueprint that reorganizes messy, site-specific patient data — claims, electronic health records, registries — into the same table structure and shared medical vocabulary at every participating site. Because every hospital or insurer stores data differently, researchers could not run the same analysis on ten databases without rewriting the code ten times; the CDM solves that problem by translating all source codes (diagnosis codes, drug codes, lab codes) into one universal language before the analysis begins. Once data live in the CDM, a single set of query code can answer the same question across dozens of databases simultaneously, with only summary results — never individual patient records — leaving each site.
OMOP CDM method patterns
are the conventional ways an RWE study is operationalized once raw claims, EHR, or registry data have been transformed (ETL'd) into the Observational Medical Outcomes Partnership Common Data Model. The CDM is a person-centric relational schema in which every clinical event is mapped to a standard concept in the OHDSI vocabulary (RxNorm for drugs, SNOMED for conditions, LOINC for measurements) and every event lives inside an OBSERVATION_PERIOD that declares when the person was observable.
The "patterns" are the reusable templates that turn a protocol — eligibility, exposure, outcome, time-at-risk, covariates — into SQL/analytic code that is portable: a study author writes the cohort logic once, and a remote site executes it against its own CDM instance and returns only aggregate results.
This is the engine behind the OHDSI network and behind FDA Sentinel-style distributed analytics. The CDM is a data structure and convention, not an estimator; the validity of any result still rests entirely on the design (active comparator, new-user, time-zero alignment) layered on top of it.
Core conceptual distinction
three CDM constructs do the heavy lifting and are routinely confused, with material consequences. (1) Raw occurrence vs era: `DRUG_EXPOSURE` and `CONDITION_OCCURRENCE` store one row per dispensing/diagnosis, whereas `DRUG_ERA` and `CONDITION_ERA` collapse those rows into continuous exposed/diseased episodes using a persistence-gap rule (the default drug-era persistence window is 30 days).
Eras are convenient for "ever on drug" or chronic-disease phenotypes but bake in a gap assumption you did not choose; for a strict new-user, time-varying analysis you usually rebuild episodes from `DRUG_EXPOSURE` with your own `days_supply` + grace-period logic rather than trusting the default era.
(2) Standard vs source concept: every event has both a `*_source_concept_id` (the original ICD/NDC code) and a mapped `*_concept_id` (the standard SNOMED/RxNorm concept). Cohorts must be defined on standard concepts and expanded through `CONCEPT_ANCESTOR` (e.g., an RxNorm ingredient resolves to all descendant clinical-drug and branded NDCs) so the same concept set behaves identically across sites — querying source codes directly destroys portability.
(3) Observability vs data presence: absence of a row is informative only inside an OBSERVATION_PERIOD; outside it, "no record" means unobserved, not "did not happen." Washout, eligibility, and censoring must all be bounded by `observation_period_start_date` / `observation_period_end_date`, or you will mistake enrollment gaps for clinical absence.
Pros, cons, and trade-offs
- vs bespoke per-database analytic code (raw claims/EHR scripts): CDM patterns give you provenance, portability, and a shared vocabulary, so a protocol can be run by 10 data partners with no PHI leaving each site and results pooled by meta-analysis (the OHDSI / FDA Sentinel model).
Cost: the ETL is lossy and opinionated — source-data nuance (claim adjustment flags, payer type, lab units edge cases) can be dropped or mis-mapped, and you inherit every ETL decision the site made. Prefer CDM patterns for multi-database, reproducible, or regulatory-grade studies; prefer bespoke code only when a single source has irreplaceable detail the CDM cannot represent and portability is not a goal.
- vs i2b2 / PCORnet / Sentinel CDMs: OMOP's distinguishing feature is full vocabulary standardization to standard concepts with `CONCEPT_ANCESTOR` hierarchies, which makes concept sets transportable; PCORnet and Sentinel keep more source-code-native structures that are simpler to ETL but harder to query identically across sites. Prefer OMOP when cross-network harmonization and large-scale analytics (PLP, LEGEND-style) matter.
- default DRUG_ERA/CONDITION_ERA vs hand-built episodes: eras are fast and standardized but the fixed persistence gap is rarely the clinically correct one and can fabricate or break exposure continuity. Prefer hand-built episodes for any time-varying or new-user safety analysis; use default eras only for coarse "ever-exposed"/prevalence characterization.
When NOT to use — and when it is actively misleading or dangerous
- You have not validated the local ETL. Treating a CDM instance as ground truth without source-to-standard mapping review and a data-quality pass (e.g., OHDSI Achilles/DataQualityDashboard) is dangerous: a missing or wrong vocabulary map silently empties or inflates a concept set, and the analysis runs cleanly while measuring the wrong thing.
- The clinical question depends on detail the ETL drops. Payer/plan type, claim line adjustments, free-text severity, units-of-measure subtleties, or device-specific NDC nuance may not survive the transform; forcing the CDM here yields confident, wrong answers.
- You trust default eras for a time-varying estimand. The 30-day drug-era persistence gap is not your protocol's grace period; using it for as-treated safety analysis silently mis-defines exposure and time-at-risk.
- You query source concepts to "be safe." Bypassing standard-concept + `CONCEPT_ANCESTOR` logic breaks portability and reintroduces the cross-site heterogeneity the CDM exists to remove.
- Out-of-observation time leaks into the analysis. Counting person-time or "absence of outcome" outside OBSERVATION_PERIOD manufactures immortal time and misclassifies unobserved as event-free.
Data-source operational depth
- Claims (FFS vs MA/commercial): ETL maps NDC→RxNorm and ICD→SNOMED; `DRUG_EXPOSURE.days_supply` and `drug_exposure_start_date` drive era and time-at-risk logic.
OBSERVATION_PERIOD is derived from enrollment spans, so it is only trustworthy when enrollment is complete: Medicare Advantage person-time lacks fee-for-service claims, so an MA-only OBSERVATION_PERIOD looks observable but is missing drug and procedure events — restrict to enrollees with full Parts A/B/D (or commercial medical+pharmacy) and treat MA-only spans as unobserved. Mail-order 90-day fills and free samples distort `days_supply`; capitated/bundled care drops claims and breaks era continuity. - EHR: Events are visit-driven, so OBSERVATION_PERIOD (often defined from first-to-last visit) overstates true observability — a patient who seeks care elsewhere is differentially "absent."
Drugs may be orders/prescriptions, not dispensings, so `DRUG_EXPOSURE` overcounts initiation unless linked to fills; conditions in `CONDITION_OCCURRENCE` mix problem-list, encounter, and resolved diagnoses, inflating `CONDITION_ERA`. Phenotypes should add measurement/lab and visit anchors, not codes alone.
- Registry: Strong, often adjudicated outcomes and disease severity (e.g., cancer stage in a tumor registry) but sparse, intermittent OBSERVATION_PERIOD coverage and weak drug capture. Map adjudicated endpoints to standard concepts carefully; link to claims for exposure and to a death index for censoring.
- Linked claims–EHR–registry: The ideal CDM substrate (EHR severity + claims completeness + adjudicated/mortality outcomes) but linkage introduces a selected subset and reconciling order vs fill vs service dates across sources must happen before time-zero assignment, or competing OBSERVATION_PERIODs will misalign follow-up. In elderly cohorts, differential competing risks (death) by exposure must be modeled, not censored away.
Worked CDM-native example
Question: incident myopathy among new users of a high-intensity statin vs a moderate-intensity statin in adults, built entirely from OMOP tables.
(1) Concept sets: resolve each statin ingredient (RxNorm) to all descendants via `CONCEPT_ANCESTOR` so every clinical-drug/branded NDC maps to the correct arm; resolve the myopathy outcome to the SNOMED standard concept and its descendants.
(2) Eligibility & washout: require ≥365 days of continuous OBSERVATION_PERIOD before the index drug-exposure, with no statin `DRUG_EXPOSURE` of either class in that window — this is the new-user/incident restriction expressed as "first qualifying `drug_exposure_start_date` with `observation_period_start_date` ≤ index − 365."
(3) Time zero & arm: index = earliest qualifying `drug_exposure_start_date`; arm = the standard ingredient dispensed that day.
(4) Exposure episodes: rebuild on-treatment time from `DRUG_EXPOSURE.days_supply` with a pre-specified grace period rather than the default 30-day DRUG_ERA, because the default gap is not the protocol's.
(5) Covariates: measured only in [index − 365, index] from `CONDITION_OCCURRENCE`, `DRUG_EXPOSURE`, `MEASUREMENT`, and `VISIT_OCCURRENCE`, feeding a (high-dimensional) propensity score.
(6) Time-at-risk & censoring: from index to first outcome `condition_occurrence`, censoring at `observation_period_end_date`, death, end of data, and (as-treated) discontinuation or switch. The identical SQL/cohort definition is then shipped to each network site; only aggregate, calibrated estimates (with negative-control empirical calibration) come back.
Decision diagram
flowchart TD Raw[Site-specific claims / EHR / registry] --> ETL[ETL: source codes -> standard concepts<br/>NDC->RxNorm, ICD->SNOMED] ETL --> CDM[(OMOP CDM tables)] CDM --> OP[OBSERVATION_PERIOD<br/>when person is observable] CDM --> DE[DRUG_EXPOSURE / DRUG_ERA<br/>exposure episodes] CDM --> CO[CONDITION_OCCURRENCE / CONDITION_ERA<br/>outcomes & comorbidity] CDM --> CA[CONCEPT_ANCESTOR<br/>ingredient -> descendant drugs] OP --> Cohort[Portable cohort definition<br/>eligibility, time zero, time-at-risk] DE --> Cohort CO --> Cohort CA --> Cohort Cohort --> Net[Same SQL runs at each network site<br/>only aggregate, calibrated results returned]
flowchart TD
Q{What does the analysis need?} -->|Coarse ever-exposed / prevalence| Era[Use DRUG_ERA / CONDITION_ERA<br/>standardized, fixed persistence gap]
Q -->|Time-varying / new-user / as-treated| Epi[Rebuild episodes from DRUG_EXPOSURE<br/>days_supply + protocol grace period]
Q -->|Portable phenotype across sites| Std[Standard concepts + CONCEPT_ANCESTOR<br/>never query source codes directly]
Q -->|Valid person-time / censoring| Obs[Bound everything by OBSERVATION_PERIOD<br/>MA-only & out-of-observation = unobserved]
Era --> Out[Defensible CDM analysis]
Epi --> Out
Std --> Out
Obs --> OutWorked example
Scenario
Imagine you want to study three different clinical questions — who received a statin, who was diagnosed with diabetes, and how many outpatient visits a patient had — and you need the analysis to run identically at five hospitals that each store data in a different format. After each hospital has converted its data to the OMOP CDM, every question maps to the same named table and the same standard concept codes, so the query code you write once will return valid results from all five sites.
Dataset
How each analytic question maps to OMOP CDM tables and the standard vocabulary concept that drives the query.
| Analytic question | OMOP CDM table(s) queried | Key column used | Standard vocabulary |
|---|---|---|---|
| Did the patient take a statin? | DRUG_EXPOSURE (individual fills) or DRUG_ERA (continuous episode) | drug_concept_id matched via CONCEPT_ANCESTOR to the atorvastatin ingredient concept | RxNorm ingredient code |
| Was the patient ever diagnosed with type 2 diabetes? | CONDITION_OCCURRENCE (individual diagnosis events) or CONDITION_ERA (continuous disease episode) | condition_concept_id matched to the SNOMED diabetes concept and its descendants | SNOMED CT code |
| How many outpatient visits did the patient have in the past year? | VISIT_OCCURRENCE filtered to visit_concept_id = outpatient visit, bounded by OBSERVATION_PERIOD dates | visit_concept_id; observation_period_start/end_date to confirm the patient was enrolled | SNOMED visit concept |
| What is the patient's baseline creatinine value? | MEASUREMENT filtered to the LOINC creatinine concept, restricted to dates before the index event | measurement_concept_id; value_as_number; measurement_date | LOINC code |
| Who is in the study population (enrolled and observable)? | PERSON joined to OBSERVATION_PERIOD | observation_period_start_date and observation_period_end_date define the window when absence of a record means the event truly did not happen | Not vocabulary-dependent — structural table |
Steps
Result
The same five-line SQL query asking 'how many new statin users had a prior diabetes diagnosis' executes correctly at all five hospital databases and returns comparable aggregate results — because the CDM guarantees that drug_concept_id, condition_concept_id, and observation_period mean the same thing everywhere. Portability is the deliverable: write the logic once, trust it to run anywhere data have been properly converted to OMOP.
Trade-offs
Runnable example
CDM-native new-user cohort construction with parameterized SQL via DuckDB/SQLAlchemy against standard OMOP tables. Required CDM tables (post-ETL, standard concepts): observation_period : person_id, observation_period_start_date, observation_period_end_date drug_exposure : person_id, drug_concept_id (standard),...
import duckdb
WASHOUT_DAYS = 365 # drug-free + continuously-observed lookback defining "new user"
STUDY_INGREDIENT_ID = 0 # standard RxNorm ingredient concept_id for the study drug
COMPARATOR_INGREDIENT_ID = 0 # standard RxNorm ingredient concept_id for the active comparator
COHORT_SQL = """
WITH arm_drugs AS ( -- expand each ingredient to all descendant standard drug concepts (portable across sites)
SELECT descendant_concept_id AS drug_concept_id, 'STUDY' AS arm
FROM concept_ancestor WHERE ancestor_concept_id = $study_ing
UNION ALL
SELECT descendant_concept_id, 'COMPARATOR'
FROM concept_ancestor WHERE ancestor_concept_id = $comp_ing
),
exposures AS ( -- every qualifying fill of either arm, tagged with its arm
SELECT de.person_id, de.drug_exposure_start_date AS dt, ad.arm
FROM drug_exposure de JOIN arm_drugs ad USING (drug_concept_id)
),
first_fill AS ( -- candidate time zero = earliest fill of either arm; arm from that day's drug
SELECT person_id, dt AS index_date, arm
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY person_id ORDER BY dt) rn FROM exposures)
WHERE rn = 1
),
new_users AS ( -- incident restriction: no study/comparator fill in the washout window before index
SELECT f.* FROM first_fill f
WHERE NOT EXISTS (
SELECT 1 FROM exposures e
WHERE e.person_id = f.person_id
AND e.dt < f.index_date
AND e.dt >= f.index_date - INTERVAL ($washout) DAY)
)
SELECT n.person_id, n.arm, n.index_date,
n.index_date - INTERVAL ($washout) DAY AS baseline_start
FROM new_users n JOIN observation_period op USING (person_id) -- observability must span the whole washout
WHERE op.observation_period_start_date <= n.index_date - INTERVAL ($washout) DAY
AND op.observation_period_end_date >= n.index_date
"""
def build_omop_new_user_cohort(con: duckdb.DuckDBPyConnection):
return con.execute(COHORT_SQL, {
"study_ing": STUDY_INGREDIENT_ID, "comp_ing": COMPARATOR_INGREDIENT_ID,
"washout": WASHOUT_DAYS,
}).df()CDM-native cohort construction using the OHDSI HADES idiom: SqlRender to author portable parameterized SQL and DatabaseConnector to execute it against a remote CDM (this is how OHDSI network studies actually ship code).
library(DatabaseConnector)
library(SqlRender)
buildOmopNewUserCohort <- function(connection, cdmDatabaseSchema,
studyIngredient, comparatorIngredient,
washoutDays = 365L, targetDialect = "postgresql") {
sql <- "
WITH arm_drugs AS ( -- expand ingredients to all descendant standard drugs (portable concept set)
SELECT descendant_concept_id AS drug_concept_id, 'STUDY' AS arm
FROM @cdm.concept_ancestor WHERE ancestor_concept_id = @study_ing
UNION ALL
SELECT descendant_concept_id, 'COMPARATOR'
FROM @cdm.concept_ancestor WHERE ancestor_concept_id = @comp_ing
),
exposures AS (
SELECT de.person_id, de.drug_exposure_start_date AS dt, ad.arm
FROM @cdm.drug_exposure de JOIN arm_drugs ad ON de.drug_concept_id = ad.drug_concept_id
),
first_fill AS (
SELECT person_id, index_date, arm FROM (
SELECT person_id, dt AS index_date, arm,
ROW_NUMBER() OVER (PARTITION BY person_id ORDER BY dt) AS rn
FROM exposures) t WHERE rn = 1
),
new_users AS ( -- new-user restriction within the washout window
SELECT f.* FROM first_fill f
WHERE NOT EXISTS (
SELECT 1 FROM exposures e
WHERE e.person_id = f.person_id AND e.dt < f.index_date
AND e.dt >= DATEADD(DAY, -@washout, f.index_date))
)
SELECT n.person_id, n.arm, n.index_date,
DATEADD(DAY, -@washout, n.index_date) AS baseline_start
FROM new_users n
JOIN @cdm.observation_period op ON op.person_id = n.person_id
WHERE op.observation_period_start_date <= DATEADD(DAY, -@washout, n.index_date)
AND op.observation_period_end_date >= n.index_date;"
rendered <- SqlRender::render(sql, cdm = cdmDatabaseSchema,
study_ing = studyIngredient, comp_ing = comparatorIngredient,
washout = washoutDays)
rendered <- SqlRender::translate(rendered, targetDialect = targetDialect)
DatabaseConnector::querySql(connection, rendered)
}CDM-native new-user cohort construction in SAS PROC SQL against standard OMOP tables in libref CDM: cdm.drug_exposure : person_id, drug_concept_id (standard), drug_exposure_start_date, days_supply cdm.observation_period : person_id, observation_period_start_date, observation_period_end_date cdm.concept_ancestor :...
%let washout = 365;
%let study_ing = 0; /* standard RxNorm ingredient concept_id: study drug */
%let comp_ing = 0; /* standard RxNorm ingredient concept_id: active comparator */
/* Expand each ingredient to all descendant standard drug concepts (portable concept set). */
proc sql;
create table arm_drugs as
select descendant_concept_id as drug_concept_id, 'STUDY' as arm length=12
from cdm.concept_ancestor where ancestor_concept_id = &study_ing
union all
select descendant_concept_id, 'COMPARATOR'
from cdm.concept_ancestor where ancestor_concept_id = &comp_ing;
/* Every qualifying fill of either arm. */
create table exposures as
select de.person_id, de.drug_exposure_start_date as dt format=date9., ad.arm
from cdm.drug_exposure de inner join arm_drugs ad
on de.drug_concept_id = ad.drug_concept_id;
quit;
/* Candidate time zero = earliest fill of either arm; arm = drug dispensed that day. */
proc sort data=exposures; by person_id dt; run;
data first_fill;
set exposures; by person_id dt;
if first.person_id;
rename dt = index_date;
run;
/* New-user restriction: exclude anyone with a study/comparator fill in the washout before index. */
proc sql;
create table new_users as
select f.* from first_fill f
where not exists (
select 1 from exposures e
where e.person_id = f.person_id
and e.dt < f.index_date
and e.dt >= f.index_date - &washout);
/* Observability must span the full washout through index (no out-of-observation time). */
create table cohort as
select n.person_id, n.arm, n.index_date,
n.index_date - &washout as baseline_start format=date9.
from new_users n
where exists (
select 1 from cdm.observation_period op
where op.person_id = n.person_id
and op.observation_period_start_date <= n.index_date - &washout
and op.observation_period_end_date >= n.index_date);
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 (MEDINFO 2015). 2015;216:574-578.
- [3]Reps JM, Schuemie MJ, Suchard MA, Ryan PB, Rijnbeek PR. Design and implementation of a standardized framework to generate and evaluate patient-level prediction models using observational healthcare data. Journal of the American Medical Informatics Association. 2018;25(8):969-975.
- [4]Hripcsak G, Ryan PB, Duke JD, et al. Characterizing treatment pathways at scale using the OHDSI network. Proceedings of the National Academy of Sciences. 2016;113(27):7329-7336.
- [5]Suchard MA, Schuemie MJ, Krumholz HM, et al. Comprehensive comparative effectiveness and safety of first-line antihypertensive drug classes: a systematic, multinational, large-scale analysis. The Lancet. 2019;394(10211):1816-1826.